style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125 insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc (singleQuote:true, semi:true, tabWidth:2, trailingComma:all, printWidth:100). Isolated per D-13-08 for reviewability.
This commit is contained in:
@@ -6,93 +6,91 @@
|
||||
* Credentials decrypted via decryptPassword before client creation (T-03-04).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, type Mock } from 'vitest';
|
||||
|
||||
// --- module-level mocks (Vitest hoisting) ---
|
||||
|
||||
const mockSyncCalendar = vi.fn().mockResolvedValue(undefined)
|
||||
const mockSyncCalendar = vi.fn().mockResolvedValue(undefined);
|
||||
|
||||
vi.mock('../../src/broker/sync.js', () => ({
|
||||
syncCalendar: mockSyncCalendar,
|
||||
}))
|
||||
}));
|
||||
|
||||
// decryptPassword returns a predictable plaintext for any input
|
||||
vi.mock('../../src/broker/crypto.js', () => ({
|
||||
decryptPassword: vi.fn().mockReturnValue('decrypted-app-password'),
|
||||
}))
|
||||
}));
|
||||
|
||||
// db mock: select() chain returns configurable results
|
||||
const mockCalendarsSelectResult: Array<{ id: number; url: string; ctag: string | null }> = []
|
||||
const mockCalendarsSelectResult: Array<{ id: number; url: string; ctag: string | null }> = [];
|
||||
const mockCredentialsSelectResult: Array<{
|
||||
id: number
|
||||
userId: number
|
||||
fastmailEmail: string
|
||||
encryptedPassword: string
|
||||
}> = []
|
||||
id: number;
|
||||
userId: number;
|
||||
fastmailEmail: string;
|
||||
encryptedPassword: string;
|
||||
}> = [];
|
||||
|
||||
// Each call to db.select() needs to return different chains
|
||||
// We use a call counter to decide which data to return
|
||||
let _callCount = 0
|
||||
let _callCount = 0;
|
||||
|
||||
const mockSelectLimit = vi.fn()
|
||||
const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockSelectLimit })
|
||||
const mockSelectFrom = vi.fn()
|
||||
const mockSelect = vi.fn().mockImplementation(() => ({ from: mockSelectFrom }))
|
||||
const mockSelectLimit = vi.fn();
|
||||
const mockSelectWhere = vi.fn().mockReturnValue({ limit: mockSelectLimit });
|
||||
const mockSelectFrom = vi.fn();
|
||||
const mockSelect = vi.fn().mockImplementation(() => ({ from: mockSelectFrom }));
|
||||
|
||||
mockSelectFrom.mockImplementation(() => ({
|
||||
// For memberCredentials selects (no .where), resolve directly
|
||||
where: mockSelectWhere,
|
||||
// Support both: direct await (no where) and .where().limit()
|
||||
then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => {
|
||||
_callCount++
|
||||
resolve(mockCredentialsSelectResult)
|
||||
return Promise.resolve(mockCredentialsSelectResult)
|
||||
_callCount++;
|
||||
resolve(mockCredentialsSelectResult);
|
||||
return Promise.resolve(mockCredentialsSelectResult);
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
mockSelectLimit.mockImplementation(() =>
|
||||
Promise.resolve(mockCalendarsSelectResult),
|
||||
)
|
||||
mockSelectLimit.mockImplementation(() => Promise.resolve(mockCalendarsSelectResult));
|
||||
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
db: { select: mockSelect },
|
||||
}))
|
||||
}));
|
||||
|
||||
// mockFetchCalendars: controlled per test
|
||||
const mockFetchCalendars = vi.fn()
|
||||
const mockFetchCalendars = vi.fn();
|
||||
const mockCreateFastmailClient = vi.fn().mockResolvedValue({
|
||||
fetchCalendars: mockFetchCalendars,
|
||||
})
|
||||
});
|
||||
|
||||
vi.mock('../../src/broker/client.js', () => ({
|
||||
createFastmailClient: mockCreateFastmailClient,
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('broker poller — runPoll', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
_callCount = 0
|
||||
vi.clearAllMocks();
|
||||
_callCount = 0;
|
||||
|
||||
// Reset implementations
|
||||
mockSyncCalendar.mockResolvedValue(undefined)
|
||||
mockCreateFastmailClient.mockResolvedValue({ fetchCalendars: mockFetchCalendars })
|
||||
mockSelect.mockImplementation(() => ({ from: mockSelectFrom }))
|
||||
mockSyncCalendar.mockResolvedValue(undefined);
|
||||
mockCreateFastmailClient.mockResolvedValue({ fetchCalendars: mockFetchCalendars });
|
||||
mockSelect.mockImplementation(() => ({ from: mockSelectFrom }));
|
||||
mockSelectFrom.mockImplementation(() => ({
|
||||
where: mockSelectWhere,
|
||||
then: (resolve: (v: typeof mockCredentialsSelectResult) => void) => {
|
||||
resolve(mockCredentialsSelectResult)
|
||||
return Promise.resolve(mockCredentialsSelectResult)
|
||||
resolve(mockCredentialsSelectResult);
|
||||
return Promise.resolve(mockCredentialsSelectResult);
|
||||
},
|
||||
}))
|
||||
mockSelectLimit.mockResolvedValue(mockCalendarsSelectResult)
|
||||
}));
|
||||
mockSelectLimit.mockResolvedValue(mockCalendarsSelectResult);
|
||||
|
||||
// Clear arrays
|
||||
mockCredentialsSelectResult.length = 0
|
||||
mockCalendarsSelectResult.length = 0
|
||||
})
|
||||
mockCredentialsSelectResult.length = 0;
|
||||
mockCalendarsSelectResult.length = 0;
|
||||
});
|
||||
|
||||
it('skips syncCalendar when ctag is unchanged', async () => {
|
||||
const { runPoll } = await import('../../src/broker/poller.js')
|
||||
const { runPoll } = await import('../../src/broker/poller.js');
|
||||
|
||||
// Set up one credential
|
||||
mockCredentialsSelectResult.push({
|
||||
@@ -100,10 +98,14 @@ describe('broker poller — runPoll', () => {
|
||||
userId: 10,
|
||||
fastmailEmail: 'lucas@fastmail.com',
|
||||
encryptedPassword: 'encrypted-blob',
|
||||
})
|
||||
});
|
||||
|
||||
// Set up the stored calendar row with ctag 'ctag-v1'
|
||||
mockCalendarsSelectResult.push({ id: 100, url: 'https://caldav.fastmail.com/cal/', ctag: 'ctag-v1' })
|
||||
mockCalendarsSelectResult.push({
|
||||
id: 100,
|
||||
url: 'https://caldav.fastmail.com/cal/',
|
||||
ctag: 'ctag-v1',
|
||||
});
|
||||
|
||||
// fetchCalendars returns a calendar with the SAME ctag
|
||||
mockFetchCalendars.mockResolvedValue([
|
||||
@@ -113,24 +115,28 @@ describe('broker poller — runPoll', () => {
|
||||
ctag: 'ctag-v1', // UNCHANGED
|
||||
syncToken: null,
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
await runPoll()
|
||||
await runPoll();
|
||||
|
||||
expect(mockSyncCalendar).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(mockSyncCalendar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('calls syncCalendar when ctag changes', async () => {
|
||||
const { runPoll } = await import('../../src/broker/poller.js')
|
||||
const { runPoll } = await import('../../src/broker/poller.js');
|
||||
|
||||
mockCredentialsSelectResult.push({
|
||||
id: 1,
|
||||
userId: 10,
|
||||
fastmailEmail: 'lucas@fastmail.com',
|
||||
encryptedPassword: 'encrypted-blob',
|
||||
})
|
||||
});
|
||||
|
||||
mockCalendarsSelectResult.push({ id: 100, url: 'https://caldav.fastmail.com/cal/', ctag: 'ctag-v1' })
|
||||
mockCalendarsSelectResult.push({
|
||||
id: 100,
|
||||
url: 'https://caldav.fastmail.com/cal/',
|
||||
ctag: 'ctag-v1',
|
||||
});
|
||||
|
||||
mockFetchCalendars.mockResolvedValue([
|
||||
{
|
||||
@@ -139,22 +145,22 @@ describe('broker poller — runPoll', () => {
|
||||
ctag: 'ctag-v2', // CHANGED
|
||||
syncToken: null,
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
await runPoll()
|
||||
await runPoll();
|
||||
|
||||
expect(mockSyncCalendar).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(mockSyncCalendar).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('calls syncCalendar when ctag was null (first sync)', async () => {
|
||||
const { runPoll } = await import('../../src/broker/poller.js')
|
||||
const { runPoll } = await import('../../src/broker/poller.js');
|
||||
|
||||
mockCredentialsSelectResult.push({
|
||||
id: 1,
|
||||
userId: 10,
|
||||
fastmailEmail: 'lucas@fastmail.com',
|
||||
encryptedPassword: 'encrypted-blob',
|
||||
})
|
||||
});
|
||||
|
||||
// No stored calendar row yet (empty array → first sync)
|
||||
// mockCalendarsSelectResult is empty
|
||||
@@ -166,80 +172,85 @@ describe('broker poller — runPoll', () => {
|
||||
ctag: 'ctag-v1',
|
||||
syncToken: null,
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
await runPoll()
|
||||
await runPoll();
|
||||
|
||||
expect(mockSyncCalendar).toHaveBeenCalledOnce()
|
||||
})
|
||||
expect(mockSyncCalendar).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('handles decryptPassword failure gracefully without crashing the poller', async () => {
|
||||
const { runPoll } = await import('../../src/broker/poller.js')
|
||||
const { decryptPassword } = await import('../../src/broker/crypto.js')
|
||||
const { runPoll } = await import('../../src/broker/poller.js');
|
||||
const { decryptPassword } = await import('../../src/broker/crypto.js');
|
||||
|
||||
mockCredentialsSelectResult.push({
|
||||
id: 1,
|
||||
userId: 10,
|
||||
fastmailEmail: 'lucas@fastmail.com',
|
||||
encryptedPassword: 'corrupted',
|
||||
})
|
||||
});
|
||||
|
||||
// Make decryptPassword throw for this test
|
||||
;(decryptPassword as Mock).mockImplementationOnce(() => {
|
||||
throw new Error('Decryption failed')
|
||||
})
|
||||
(decryptPassword as Mock).mockImplementationOnce(() => {
|
||||
throw new Error('Decryption failed');
|
||||
});
|
||||
|
||||
// runPoll should not throw — it should catch and skip the credential
|
||||
await expect(runPoll()).resolves.not.toThrow()
|
||||
expect(mockSyncCalendar).not.toHaveBeenCalled()
|
||||
})
|
||||
await expect(runPoll()).resolves.not.toThrow();
|
||||
expect(mockSyncCalendar).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('BUG B: scopes the stored-calendar lookup to (userId, url), not url alone', async () => {
|
||||
// Capture the predicate passed to db.select().from(calendars).where(...).
|
||||
// The buggy code passed eq(url) only; the fix passes and(eq(userId), eq(url)).
|
||||
// We serialize the predicate and assert it references the member's user_id column.
|
||||
const capturedWhere: unknown[] = []
|
||||
const capturedWhere: unknown[] = [];
|
||||
mockSelectWhere.mockImplementation((pred: unknown) => {
|
||||
capturedWhere.push(pred)
|
||||
return { limit: mockSelectLimit }
|
||||
})
|
||||
capturedWhere.push(pred);
|
||||
return { limit: mockSelectLimit };
|
||||
});
|
||||
|
||||
const { runPoll } = await import('../../src/broker/poller.js')
|
||||
const { runPoll } = await import('../../src/broker/poller.js');
|
||||
|
||||
mockCredentialsSelectResult.push({
|
||||
id: 7,
|
||||
userId: 42,
|
||||
fastmailEmail: 'lucas@fastmail.com',
|
||||
encryptedPassword: 'enc',
|
||||
})
|
||||
});
|
||||
|
||||
mockFetchCalendars.mockResolvedValue([
|
||||
{ url: 'https://caldav.fastmail.com/cal/', displayName: 'Calendar', ctag: 'c', syncToken: null },
|
||||
])
|
||||
{
|
||||
url: 'https://caldav.fastmail.com/cal/',
|
||||
displayName: 'Calendar',
|
||||
ctag: 'c',
|
||||
syncToken: null,
|
||||
},
|
||||
]);
|
||||
|
||||
await runPoll()
|
||||
await runPoll();
|
||||
|
||||
expect(capturedWhere.length).toBeGreaterThan(0)
|
||||
expect(capturedWhere.length).toBeGreaterThan(0);
|
||||
// A composite and(...) predicate exposes multiple queryChunks; a single eq does not
|
||||
// contain a nested SQL referencing the user_id column. Serialize and inspect.
|
||||
const pred = capturedWhere[0] as { queryChunks?: unknown[] }
|
||||
const pred = capturedWhere[0] as { queryChunks?: unknown[] };
|
||||
const serialized = JSON.stringify(pred, (_k, v) =>
|
||||
typeof v === 'object' && v !== null && 'name' in (v as Record<string, unknown>)
|
||||
? (v as { name?: unknown }).name
|
||||
: v,
|
||||
)
|
||||
expect(serialized).toContain('user_id')
|
||||
expect(serialized).toContain('url')
|
||||
})
|
||||
);
|
||||
expect(serialized).toContain('user_id');
|
||||
expect(serialized).toContain('url');
|
||||
});
|
||||
|
||||
it('processes all member credentials in a poll cycle', async () => {
|
||||
const { runPoll } = await import('../../src/broker/poller.js')
|
||||
const { runPoll } = await import('../../src/broker/poller.js');
|
||||
|
||||
// Two credentials
|
||||
mockCredentialsSelectResult.push(
|
||||
{ id: 1, userId: 10, fastmailEmail: 'lucas@fastmail.com', encryptedPassword: 'enc1' },
|
||||
{ id: 2, userId: 20, fastmailEmail: 'wife@icloud.com', encryptedPassword: 'enc2' },
|
||||
)
|
||||
);
|
||||
|
||||
// Each member's calendar has a different (new) ctag → both trigger sync
|
||||
mockFetchCalendars.mockResolvedValue([
|
||||
@@ -249,13 +260,13 @@ describe('broker poller — runPoll', () => {
|
||||
ctag: 'new-ctag',
|
||||
syncToken: null,
|
||||
},
|
||||
])
|
||||
]);
|
||||
|
||||
await runPoll()
|
||||
await runPoll();
|
||||
|
||||
// createFastmailClient called once per credential
|
||||
expect(mockCreateFastmailClient).toHaveBeenCalledTimes(2)
|
||||
expect(mockCreateFastmailClient).toHaveBeenCalledTimes(2);
|
||||
// syncCalendar called once per credential (one calendar each, ctag changed)
|
||||
expect(mockSyncCalendar).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
expect(mockSyncCalendar).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user