Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
],
"dependencies": {
"@nbtca/docs": "^0.3.2",
"@nbtca/nbtcal": "^0.4.2",
"@nbtca/nbtcal": "^0.4.3",
"chalk": "^5.6.2",
"marked": "^15.0.12"
},
Expand Down
171 changes: 143 additions & 28 deletions src/features/calendar-load.test.ts
Original file line number Diff line number Diff line change
@@ -1,44 +1,159 @@
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import type * as Nbtcal from '@nbtca/nbtcal';
import type { FeedCache } from './calendar-store.js';
import type * as CalendarModule from './calendar.js';

const FEED = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
'UID:cached',
'SUMMARY:Cached',
'DTSTART:20260926T190000Z',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
function feed(uid: string): string {
return [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'BEGIN:VEVENT',
`UID:${uid}`,
'SUMMARY:Event',
'DTSTART:20260926T190000Z',
'END:VEVENT',
'END:VCALENDAR',
].join('\r\n');
}

const fetchFeed = vi.fn();
const loadFeedCache = vi.fn<() => string | null>();
const fetchFeedConditional = vi.fn();
const parseCalendar = vi.fn();
const loadFeedCache = vi.fn<() => FeedCache | null>();
const saveFeedCache = vi.fn();
const touchFeedCache = vi.fn();

vi.mock('@nbtca/nbtcal', async (importOriginal) => ({
...(await importOriginal<typeof Nbtcal>()),
fetchFeed,
}));
vi.mock('./calendar-store.js', () => ({ loadFeedCache, saveFeedCache: vi.fn() }));
vi.mock('@nbtca/nbtcal', async (importOriginal) => {
const original = await importOriginal<typeof Nbtcal>();
parseCalendar.mockImplementation(original.parseCalendar);
return { ...original, fetchFeedConditional, parseCalendar };
});
vi.mock('./calendar-store.js', () => ({ loadFeedCache, saveFeedCache, touchFeedCache }));

const cached: FeedCache = { text: feed('cached'), validators: { etag: '"v1"' } };

async function freshCalendar(): Promise<typeof CalendarModule> {
vi.resetModules();
return import('./calendar.js');
}

function uids(calendar: Nbtcal.Calendar): string[] {
return calendar.inRange(new Date(0), new Date(2030, 0, 1)).map((e) => e.uid);
}

const { loadCalendarOrCache } = await import('./calendar.js');
function sentValidators(): unknown {
return (fetchFeedConditional.mock.calls.at(-1)?.[1] as Nbtcal.FetchFeedConditionalOptions)
.validators;
}

beforeEach(() => {
vi.useRealTimers();
fetchFeedConditional.mockReset();
loadFeedCache.mockReset();
saveFeedCache.mockReset();
touchFeedCache.mockReset();
parseCalendar.mockClear();
});

describe('loadCalendarOrCache', () => {
it('falls back to the cached feed when the network fails', async () => {
fetchFeed.mockRejectedValue(new Error('offline'));
loadFeedCache.mockReturnValue(FEED);
const { loadCalendarOrCache } = await freshCalendar();
fetchFeedConditional.mockRejectedValue(new Error('offline'));
loadFeedCache.mockReturnValue(cached);
const { calendar, stale } = await loadCalendarOrCache();
expect(stale).toBe(true);
expect(calendar.inRange(new Date(0), new Date(2030, 0, 1)).map((e) => e.uid)).toEqual([
'cached',
]);
expect(uids(calendar)).toEqual(['cached']);
});

it('fails when there is nothing cached', async () => {
vi.resetModules();
const fresh = await import('./calendar.js');
fetchFeed.mockRejectedValue(new Error('offline'));
const { loadCalendarOrCache } = await freshCalendar();
fetchFeedConditional.mockRejectedValue(new Error('offline'));
loadFeedCache.mockReturnValue(null);
await expect(loadCalendarOrCache()).rejects.toThrow('offline');
});
});

describe('loadCalendarOrThrow', () => {
it('fetches unconditionally without a cached feed and stores the response', async () => {
const { loadCalendarOrThrow } = await freshCalendar();
loadFeedCache.mockReturnValue(null);
const result = { status: 'modified', text: feed('fresh'), validators: { etag: '"v2"' } };
fetchFeedConditional.mockResolvedValue(result);

expect(uids(await loadCalendarOrThrow())).toEqual(['fresh']);
expect(sentValidators()).toEqual({});
expect(saveFeedCache).toHaveBeenCalledWith(result);
});

it('reuses the peeked calendar on 304 without parsing it again', async () => {
const { loadCalendarOrThrow, peekCalendar } = await freshCalendar();
loadFeedCache.mockReturnValue(cached);
const peeked = peekCalendar();
fetchFeedConditional.mockResolvedValue({
status: 'not-modified',
validators: { etag: '"v1"' },
});

expect(await loadCalendarOrThrow()).toBe(peeked);
expect(sentValidators()).toEqual({ etag: '"v1"' });
expect(parseCalendar).toHaveBeenCalledTimes(1);
expect(saveFeedCache).not.toHaveBeenCalled();
expect(touchFeedCache).toHaveBeenCalledWith({ etag: '"v1"' });

await loadCalendarOrThrow();
expect(fetchFeedConditional).toHaveBeenCalledTimes(1);
});

it('revalidates with the latest validators after the memo expires', async () => {
vi.useFakeTimers();
const { loadCalendarOrThrow } = await freshCalendar();
loadFeedCache.mockReturnValue(cached);
fetchFeedConditional.mockResolvedValue({
status: 'not-modified',
validators: { etag: '"v1"', lastModified: 'Thu, 24 Sep 2026 10:00:00 GMT' },
});
await loadCalendarOrThrow();
vi.advanceTimersByTime(6 * 60 * 1000);
await loadCalendarOrThrow();

expect(fetchFeedConditional).toHaveBeenCalledTimes(2);
expect(sentValidators()).toEqual({
etag: '"v1"',
lastModified: 'Thu, 24 Sep 2026 10:00:00 GMT',
});
expect(parseCalendar).toHaveBeenCalledTimes(1);
});

it('replaces the calendar and cache on 200', async () => {
const { loadCalendarOrThrow, peekCalendar } = await freshCalendar();
loadFeedCache.mockReturnValue(cached);
peekCalendar();
const result = { status: 'modified', text: feed('changed'), validators: { etag: '"v2"' } };
fetchFeedConditional.mockResolvedValue(result);

const calendar = await loadCalendarOrThrow();
expect(uids(calendar)).toEqual(['changed']);
expect(peekCalendar()).toBe(calendar);
expect(saveFeedCache).toHaveBeenCalledWith(result);
expect(touchFeedCache).not.toHaveBeenCalled();
});

it('fetches unconditionally when the cached feed cannot be parsed', async () => {
const { loadCalendarOrThrow } = await freshCalendar();
loadFeedCache.mockReturnValue({ text: 'garbage', validators: { etag: '"v1"' } });
fetchFeedConditional.mockResolvedValue({
status: 'modified',
text: feed('fresh'),
validators: {},
});

expect(uids(await loadCalendarOrThrow())).toEqual(['fresh']);
expect(sentValidators()).toEqual({});
});

it('rejects a 304 it did not ask for', async () => {
const { loadCalendarOrThrow } = await freshCalendar();
loadFeedCache.mockReturnValue(null);
await expect(fresh.loadCalendarOrCache()).rejects.toThrow('offline');
fetchFeedConditional.mockResolvedValue({ status: 'not-modified', validators: {} });
await expect(loadCalendarOrThrow()).rejects.toThrow('HTTP 304');
});
});
86 changes: 63 additions & 23 deletions src/features/calendar-store.test.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,85 @@
import { describe, it, expect } from 'vitest';
import { mkdtempSync, rmSync, utimesSync } from 'fs';
import { mkdtempSync, rmSync, statSync, utimesSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import { saveFeedCache, loadFeedCache } from './calendar-store.js';
import { saveFeedCache, loadFeedCache, touchFeedCache } from './calendar-store.js';

const validators = { etag: '"v1"', lastModified: 'Wed, 23 Sep 2026 10:00:00 GMT' };

function withDir(run: (dir: string) => void): void {
const dir = mkdtempSync(join(tmpdir(), 'cal-'));
try {
run(dir);
} finally {
rmSync(dir, { recursive: true, force: true });
}
}

describe('calendar-store', () => {
it('round-trips the feed via an injected dir', () => {
const dir = mkdtempSync(join(tmpdir(), 'cal-'));
try {
saveFeedCache('BEGIN:VCALENDAR\nEND:VCALENDAR', dir);
expect(loadFeedCache(dir)).toBe('BEGIN:VCALENDAR\nEND:VCALENDAR');
} finally {
rmSync(dir, { recursive: true, force: true });
}
it('round-trips the feed and its validators via an injected dir', () => {
withDir((dir) => {
saveFeedCache({ text: 'BEGIN:VCALENDAR\nEND:VCALENDAR', validators }, dir);
expect(loadFeedCache(dir)).toEqual({ text: 'BEGIN:VCALENDAR\nEND:VCALENDAR', validators });
});
});

it.runIf(process.platform !== 'win32')('keeps both files private', () => {
withDir((dir) => {
saveFeedCache({ text: 'x', validators }, dir);
expect(statSync(join(dir, 'calendar-feed.ics')).mode & 0o777).toBe(0o600);
expect(statSync(join(dir, 'calendar-feed.json')).mode & 0o777).toBe(0o600);
});
});

it('reports a miss when nothing was cached', () => {
const dir = mkdtempSync(join(tmpdir(), 'cal-'));
try {
withDir((dir) => {
expect(loadFeedCache(dir)).toBeNull();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
});

it.each([
['missing', null],
['corrupt', '{not json'],
['not an object', 'null'],
['an unsafe header value', JSON.stringify({ etag: '"v1"\r\nX: y', lastModified: 42 })],
])('drops validators that are %s', (_label, content) => {
withDir((dir) => {
saveFeedCache({ text: 'x', validators }, dir);
const file = join(dir, 'calendar-feed.json');
if (content === null) rmSync(file);
else writeFileSync(file, content);
expect(loadFeedCache(dir)).toEqual({ text: 'x', validators: {} });
});
});

it('refuses a feed older than the max age', () => {
const dir = mkdtempSync(join(tmpdir(), 'cal-'));
try {
saveFeedCache('stale', dir);
withDir((dir) => {
saveFeedCache({ text: 'stale', validators: {} }, dir);
const longAgo = new Date(Date.now() - 60 * 60 * 1000);
utimesSync(join(dir, 'calendar-feed.ics'), longAgo, longAgo);
expect(loadFeedCache(dir, 30 * 60 * 1000)).toBeNull();
expect(loadFeedCache(dir, 2 * 60 * 60 * 1000)).toBe('stale');
} finally {
rmSync(dir, { recursive: true, force: true });
}
expect(loadFeedCache(dir, 2 * 60 * 60 * 1000)?.text).toBe('stale');
});
});

it('renews the feed age and validators when the feed is unchanged', () => {
withDir((dir) => {
saveFeedCache({ text: 'same', validators }, dir);
const longAgo = new Date(Date.now() - 60 * 60 * 1000);
utimesSync(join(dir, 'calendar-feed.ics'), longAgo, longAgo);
touchFeedCache({ etag: '"v2"' }, dir);
expect(loadFeedCache(dir, 30 * 60 * 1000)).toEqual({
text: 'same',
validators: { etag: '"v2"' },
});
});
});

it('stays quiet when the directory cannot be written', () => {
const missing = join(tmpdir(), 'cal-missing', 'deeper');
expect(() => {
saveFeedCache('x', join(tmpdir(), 'cal-missing', 'deeper'));
saveFeedCache({ text: 'x', validators }, missing);
touchFeedCache(validators, missing);
}).not.toThrow();
});
});
Loading
Loading