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:
@@ -7,90 +7,90 @@
|
||||
* 3. NODE_ENV!='production' + DEV_AUTH_BYPASS='true' → DEV_USER injected into context
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { Hono } from 'hono'
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
// We import after env manipulation since devAuthBypass() reads env vars at call time.
|
||||
// Each test resets the module registry via vi.resetModules() to re-evaluate the function
|
||||
// with the current process.env values.
|
||||
|
||||
describe('devAuthBypass middleware', () => {
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
const originalBypassFlag = process.env.DEV_AUTH_BYPASS
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
const originalBypassFlag = process.env.DEV_AUTH_BYPASS;
|
||||
|
||||
afterEach(() => {
|
||||
// Restore env after each test
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
if (originalBypassFlag === undefined) {
|
||||
delete process.env.DEV_AUTH_BYPASS
|
||||
delete process.env.DEV_AUTH_BYPASS;
|
||||
} else {
|
||||
process.env.DEV_AUTH_BYPASS = originalBypassFlag
|
||||
process.env.DEV_AUTH_BYPASS = originalBypassFlag;
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('is a pure passthrough in production (NODE_ENV=production), even when DEV_AUTH_BYPASS=true', async () => {
|
||||
process.env.NODE_ENV = 'production'
|
||||
process.env.DEV_AUTH_BYPASS = 'true'
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.DEV_AUTH_BYPASS = 'true';
|
||||
|
||||
// Import after env setup
|
||||
const { devAuthBypass } = await import('../../src/auth/devBypass.js')
|
||||
const { devAuthBypass } = await import('../../src/auth/devBypass.js');
|
||||
|
||||
const app = new Hono()
|
||||
app.use('/api/*', devAuthBypass())
|
||||
const app = new Hono();
|
||||
app.use('/api/*', devAuthBypass());
|
||||
|
||||
let capturedUser: unknown = undefined
|
||||
let capturedUser: unknown = undefined;
|
||||
app.get('/api/test', (c) => {
|
||||
capturedUser = c.get('user')
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
capturedUser = c.get('user');
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
// Hard guard: user must NOT be injected in production
|
||||
expect(capturedUser).toBeUndefined()
|
||||
})
|
||||
expect(capturedUser).toBeUndefined();
|
||||
});
|
||||
|
||||
it('is a passthrough when NODE_ENV!=production and DEV_AUTH_BYPASS is not set', async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
delete process.env.DEV_AUTH_BYPASS
|
||||
process.env.NODE_ENV = 'test';
|
||||
delete process.env.DEV_AUTH_BYPASS;
|
||||
|
||||
const { devAuthBypass } = await import('../../src/auth/devBypass.js')
|
||||
const { devAuthBypass } = await import('../../src/auth/devBypass.js');
|
||||
|
||||
const app = new Hono()
|
||||
app.use('/api/*', devAuthBypass())
|
||||
const app = new Hono();
|
||||
app.use('/api/*', devAuthBypass());
|
||||
|
||||
let capturedUser: unknown = undefined
|
||||
let capturedUser: unknown = undefined;
|
||||
app.get('/api/test', (c) => {
|
||||
capturedUser = c.get('user')
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
capturedUser = c.get('user');
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
expect(res.status).toBe(200)
|
||||
expect(capturedUser).toBeUndefined()
|
||||
})
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
expect(capturedUser).toBeUndefined();
|
||||
});
|
||||
|
||||
it('injects DEV_USER when NODE_ENV!=production and DEV_AUTH_BYPASS=true', async () => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DEV_AUTH_BYPASS = 'true'
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DEV_AUTH_BYPASS = 'true';
|
||||
|
||||
const { devAuthBypass, DEV_USER } = await import('../../src/auth/devBypass.js')
|
||||
const { devAuthBypass, DEV_USER } = await import('../../src/auth/devBypass.js');
|
||||
|
||||
const app = new Hono()
|
||||
app.use('/api/*', devAuthBypass())
|
||||
const app = new Hono();
|
||||
app.use('/api/*', devAuthBypass());
|
||||
|
||||
let capturedUser: unknown = undefined
|
||||
let capturedUser: unknown = undefined;
|
||||
app.get('/api/test', (c) => {
|
||||
capturedUser = c.get('user')
|
||||
return c.json({ ok: true })
|
||||
})
|
||||
capturedUser = c.get('user');
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
// User must be the fixed DEV_USER
|
||||
expect(capturedUser).toBeDefined()
|
||||
expect(capturedUser).toEqual(DEV_USER)
|
||||
expect((capturedUser as typeof DEV_USER).displayName).toBe('Dev User')
|
||||
expect((capturedUser as typeof DEV_USER).oidcSub).toBe('dev-user')
|
||||
})
|
||||
})
|
||||
expect(capturedUser).toBeDefined();
|
||||
expect(capturedUser).toEqual(DEV_USER);
|
||||
expect((capturedUser as typeof DEV_USER).displayName).toBe('Dev User');
|
||||
expect((capturedUser as typeof DEV_USER).oidcSub).toBe('dev-user');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,112 +11,110 @@
|
||||
* pure unit test that runs without MariaDB or any external service.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, afterEach } from 'vitest'
|
||||
import { Hono } from 'hono'
|
||||
import { persistSessionCookie } from '../../src/auth/persistSessionCookie.js'
|
||||
import { describe, it, expect, afterEach } from 'vitest';
|
||||
import { Hono } from 'hono';
|
||||
import { persistSessionCookie } from '../../src/auth/persistSessionCookie.js';
|
||||
|
||||
// Read the cookie name the same way the implementation does so the test stays correct
|
||||
// if OIDC_COOKIE_NAME is overridden in the environment.
|
||||
const COOKIE_NAME = process.env.OIDC_COOKIE_NAME ?? 'oidc-auth'
|
||||
const COOKIE_NAME = process.env.OIDC_COOKIE_NAME ?? 'oidc-auth';
|
||||
|
||||
afterEach(() => {
|
||||
// Nothing to restore: these tests do not mutate process.env.
|
||||
})
|
||||
});
|
||||
|
||||
describe('persistSessionCookie middleware', () => {
|
||||
describe('Test A — persist path (oidcAuthJwt truthy)', () => {
|
||||
it('emits an oidc-auth Set-Cookie with Max-Age, SameSite=Lax, HttpOnly, and Secure', async () => {
|
||||
const app = new Hono()
|
||||
const app = new Hono();
|
||||
|
||||
// Simulate what @hono/oidc-auth does: set a signed session JWT on context.
|
||||
app.use('/api/*', async (c, next) => {
|
||||
c.set('oidcAuthJwt' as never, 'header.payload.sig')
|
||||
await next()
|
||||
})
|
||||
c.set('oidcAuthJwt' as never, 'header.payload.sig');
|
||||
await next();
|
||||
});
|
||||
|
||||
app.use('/api/*', persistSessionCookie())
|
||||
app.use('/api/*', persistSessionCookie());
|
||||
|
||||
app.get('/api/test', (c) => c.json({ ok: true }))
|
||||
app.get('/api/test', (c) => c.json({ ok: true }));
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const setCookieHeader = res.headers.get('set-cookie')
|
||||
expect(setCookieHeader).not.toBeNull()
|
||||
const setCookieHeader = res.headers.get('set-cookie');
|
||||
expect(setCookieHeader).not.toBeNull();
|
||||
|
||||
// Cookie name must appear in the Set-Cookie value.
|
||||
expect(setCookieHeader).toContain(COOKIE_NAME)
|
||||
expect(setCookieHeader).toContain(COOKIE_NAME);
|
||||
|
||||
// Max-Age must be present (persistent cookie, not session-scoped).
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('max-age')
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('max-age');
|
||||
|
||||
// SameSite=Lax must be present.
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax')
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('samesite=lax');
|
||||
|
||||
// HttpOnly must be present.
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('httponly')
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('httponly');
|
||||
|
||||
// Secure must be present.
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('secure')
|
||||
})
|
||||
expect(setCookieHeader?.toLowerCase()).toContain('secure');
|
||||
});
|
||||
|
||||
it('re-issues the same JWT value the library provided (no re-signing)', async () => {
|
||||
const dummyJwt = 'header.payload.sig'
|
||||
const app = new Hono()
|
||||
const dummyJwt = 'header.payload.sig';
|
||||
const app = new Hono();
|
||||
|
||||
app.use('/api/*', async (c, next) => {
|
||||
c.set('oidcAuthJwt' as never, dummyJwt)
|
||||
await next()
|
||||
})
|
||||
c.set('oidcAuthJwt' as never, dummyJwt);
|
||||
await next();
|
||||
});
|
||||
|
||||
app.use('/api/*', persistSessionCookie())
|
||||
app.get('/api/test', (c) => c.json({ ok: true }))
|
||||
app.use('/api/*', persistSessionCookie());
|
||||
app.get('/api/test', (c) => c.json({ ok: true }));
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
const setCookieHeader = res.headers.get('set-cookie') ?? ''
|
||||
const res = await app.request('/api/test');
|
||||
const setCookieHeader = res.headers.get('set-cookie') ?? '';
|
||||
|
||||
// The cookie value must contain the exact JWT string (URL-encoded = is fine but
|
||||
// the JWT characters must all appear).
|
||||
expect(setCookieHeader).toContain(dummyJwt)
|
||||
})
|
||||
})
|
||||
expect(setCookieHeader).toContain(dummyJwt);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test B — guard path (oidcAuthJwt falsy / absent)', () => {
|
||||
it('emits NO oidc-auth Set-Cookie when oidcAuthJwt is not set (no resurrection)', async () => {
|
||||
const app = new Hono()
|
||||
const app = new Hono();
|
||||
|
||||
// Do NOT set oidcAuthJwt — simulates a logged-out or unauthenticated request.
|
||||
app.use('/api/*', persistSessionCookie())
|
||||
app.get('/api/test', (c) => c.json({ ok: true }))
|
||||
app.use('/api/*', persistSessionCookie());
|
||||
app.get('/api/test', (c) => c.json({ ok: true }));
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.request('/api/test');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const setCookieHeader = res.headers.get('set-cookie')
|
||||
const setCookieHeader = res.headers.get('set-cookie');
|
||||
|
||||
// Either no Set-Cookie header at all, or it must not contain the oidc-auth cookie.
|
||||
const hasOidcCookie =
|
||||
setCookieHeader !== null && setCookieHeader.includes(COOKIE_NAME)
|
||||
expect(hasOidcCookie).toBe(false)
|
||||
})
|
||||
const hasOidcCookie = setCookieHeader !== null && setCookieHeader.includes(COOKIE_NAME);
|
||||
expect(hasOidcCookie).toBe(false);
|
||||
});
|
||||
|
||||
it('emits NO Set-Cookie when oidcAuthJwt is explicitly set to empty string', async () => {
|
||||
const app = new Hono()
|
||||
const app = new Hono();
|
||||
|
||||
app.use('/api/*', async (c, next) => {
|
||||
c.set('oidcAuthJwt' as never, '')
|
||||
await next()
|
||||
})
|
||||
c.set('oidcAuthJwt' as never, '');
|
||||
await next();
|
||||
});
|
||||
|
||||
app.use('/api/*', persistSessionCookie())
|
||||
app.get('/api/test', (c) => c.json({ ok: true }))
|
||||
app.use('/api/*', persistSessionCookie());
|
||||
app.get('/api/test', (c) => c.json({ ok: true }));
|
||||
|
||||
const res = await app.request('/api/test')
|
||||
const setCookieHeader = res.headers.get('set-cookie')
|
||||
const res = await app.request('/api/test');
|
||||
const setCookieHeader = res.headers.get('set-cookie');
|
||||
|
||||
const hasOidcCookie =
|
||||
setCookieHeader !== null && setCookieHeader.includes(COOKIE_NAME)
|
||||
expect(hasOidcCookie).toBe(false)
|
||||
})
|
||||
})
|
||||
})
|
||||
const hasOidcCookie = setCookieHeader !== null && setCookieHeader.includes(COOKIE_NAME);
|
||||
expect(hasOidcCookie).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+130
-102
@@ -4,7 +4,7 @@
|
||||
* Tests for apps/api/src/auth/user.ts (Plan 02)
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the db singleton at module level (Vitest hoisting — must be top-level)
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
@@ -12,16 +12,16 @@ vi.mock('../../src/db/client.js', () => ({
|
||||
select: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
// Import after mock is set up
|
||||
import { db } from '../../src/db/client.js'
|
||||
import { upsertUser, COLOR_PALETTE } from '../../src/auth/user.js'
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { upsertUser, COLOR_PALETTE } from '../../src/auth/user.js';
|
||||
|
||||
const mockDb = db as {
|
||||
select: ReturnType<typeof vi.fn>
|
||||
insert: ReturnType<typeof vi.fn>
|
||||
}
|
||||
select: ReturnType<typeof vi.fn>;
|
||||
insert: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
// Chainable builder factory used in multiple tests
|
||||
function makeSelectChain(resolvedValue: unknown[]) {
|
||||
@@ -29,140 +29,161 @@ function makeSelectChain(resolvedValue: unknown[]) {
|
||||
from: vi.fn(),
|
||||
where: vi.fn(),
|
||||
limit: vi.fn().mockResolvedValue(resolvedValue),
|
||||
}
|
||||
chain.from.mockReturnValue(chain)
|
||||
chain.where.mockReturnValue(chain)
|
||||
return chain
|
||||
};
|
||||
chain.from.mockReturnValue(chain);
|
||||
chain.where.mockReturnValue(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
function makeInsertChain(returningIdValue: { id: number }[]) {
|
||||
const chain = {
|
||||
values: vi.fn(),
|
||||
$returningId: vi.fn().mockResolvedValue(returningIdValue),
|
||||
}
|
||||
chain.values.mockReturnValue(chain)
|
||||
return chain
|
||||
};
|
||||
chain.values.mockReturnValue(chain);
|
||||
return chain;
|
||||
}
|
||||
|
||||
describe('COLOR_PALETTE', () => {
|
||||
it('exports at least 4 distinct hex colors', () => {
|
||||
expect(COLOR_PALETTE).toBeDefined()
|
||||
expect(COLOR_PALETTE.length).toBeGreaterThanOrEqual(4)
|
||||
expect(COLOR_PALETTE).toBeDefined();
|
||||
expect(COLOR_PALETTE.length).toBeGreaterThanOrEqual(4);
|
||||
for (const c of COLOR_PALETTE) {
|
||||
// Each entry must be a 7-char hex string like #4A90D9
|
||||
expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/)
|
||||
expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
}
|
||||
// All colors must be distinct
|
||||
const unique = new Set(COLOR_PALETTE)
|
||||
expect(unique.size).toBe(COLOR_PALETTE.length)
|
||||
})
|
||||
})
|
||||
const unique = new Set(COLOR_PALETTE);
|
||||
expect(unique.size).toBe(COLOR_PALETTE.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertUser', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('assigns palette[0] to the first user inserted', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub = 'user-sub-001'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-001';
|
||||
|
||||
// First select: no existing user
|
||||
// Second select (used colors): no existing users → no colors in use → palette[0]
|
||||
// Third select (re-fetch after insert): return the inserted row
|
||||
let selectCallCount = 0
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) {
|
||||
// Lookup by iss+sub — not found
|
||||
return makeSelectChain([])
|
||||
return makeSelectChain([]);
|
||||
}
|
||||
if (selectCallCount === 2) {
|
||||
// Used-colors query — no existing users
|
||||
return {
|
||||
from: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
};
|
||||
}
|
||||
// Re-fetch after insert
|
||||
return makeSelectChain([
|
||||
{ id: 1, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], createdAt: new Date() },
|
||||
])
|
||||
})
|
||||
{
|
||||
id: 1,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[0],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 1 }]))
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 1 }]));
|
||||
|
||||
const user = await upsertUser(iss, sub)
|
||||
const user = await upsertUser(iss, sub);
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user!.color).toBe(COLOR_PALETTE[0])
|
||||
expect(user!.id).toBe(1)
|
||||
})
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.color).toBe(COLOR_PALETTE[0]);
|
||||
expect(user!.id).toBe(1);
|
||||
});
|
||||
|
||||
it('assigns palette[1] to the second distinct user', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub2 = 'user-sub-002'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub2 = 'user-sub-002';
|
||||
|
||||
let selectCallCount = 0
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) {
|
||||
return makeSelectChain([]) // not found
|
||||
return makeSelectChain([]); // not found
|
||||
}
|
||||
if (selectCallCount === 2) {
|
||||
// Used-colors query — one existing user already holds palette[0],
|
||||
// so the next member must get the first unused color: palette[1].
|
||||
return {
|
||||
from: vi.fn().mockResolvedValue([{ color: COLOR_PALETTE[0] }]),
|
||||
}
|
||||
};
|
||||
}
|
||||
return makeSelectChain([
|
||||
{ id: 2, oidcIss: iss, oidcSub: sub2, displayName: null, color: COLOR_PALETTE[1], createdAt: new Date() },
|
||||
])
|
||||
})
|
||||
{
|
||||
id: 2,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub2,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[1],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 2 }]))
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 2 }]));
|
||||
|
||||
const user = await upsertUser(iss, sub2)
|
||||
const user = await upsertUser(iss, sub2);
|
||||
|
||||
expect(user!.color).toBe(COLOR_PALETTE[1])
|
||||
})
|
||||
expect(user!.color).toBe(COLOR_PALETTE[1]);
|
||||
});
|
||||
|
||||
// Regression (Gate 2): a new member must get a color NOT already in use, even
|
||||
// after a deletion. The old COUNT(*) % palette logic reused an in-use slot
|
||||
// when the user count had shifted (two members both got #E8734A). With colors
|
||||
// [0] and [2] taken (slot [1] freed by a delete), the next member fills [1].
|
||||
it('assigns the first UNUSED palette color (no collision after deletions)', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub = 'user-sub-005'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-005';
|
||||
|
||||
let selectCallCount = 0
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++
|
||||
if (selectCallCount === 1) return makeSelectChain([]) // not found
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) return makeSelectChain([]); // not found
|
||||
if (selectCallCount === 2) {
|
||||
// palette[0] and palette[2] in use; palette[1] is free
|
||||
return {
|
||||
from: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ color: COLOR_PALETTE[0] }, { color: COLOR_PALETTE[2] }]),
|
||||
}
|
||||
};
|
||||
}
|
||||
return makeSelectChain([
|
||||
{ id: 5, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[1], createdAt: new Date() },
|
||||
])
|
||||
})
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 5 }]))
|
||||
{
|
||||
id: 5,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[1],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 5 }]));
|
||||
|
||||
await upsertUser(iss, sub)
|
||||
await upsertUser(iss, sub);
|
||||
|
||||
// The inserted row's color must be the first unused palette entry (palette[1]).
|
||||
const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]
|
||||
expect(insertValues.color).toBe(COLOR_PALETTE[1])
|
||||
})
|
||||
const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0];
|
||||
expect(insertValues.color).toBe(COLOR_PALETTE[1]);
|
||||
});
|
||||
|
||||
it('returns the same user row on re-upsert (idempotent — no duplicate insert)', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub = 'user-sub-001'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-001';
|
||||
const existingRow = {
|
||||
id: 1,
|
||||
oidcIss: iss,
|
||||
@@ -170,51 +191,58 @@ describe('upsertUser', () => {
|
||||
displayName: 'Lucas',
|
||||
color: COLOR_PALETTE[0],
|
||||
createdAt: new Date(),
|
||||
}
|
||||
};
|
||||
|
||||
// select returns existing row immediately
|
||||
mockDb.select.mockImplementation(() => makeSelectChain([existingRow]))
|
||||
mockDb.select.mockImplementation(() => makeSelectChain([existingRow]));
|
||||
|
||||
const user = await upsertUser(iss, sub, 'Lucas')
|
||||
const user = await upsertUser(iss, sub, 'Lucas');
|
||||
|
||||
// Must NOT call insert (idempotent path)
|
||||
expect(mockDb.insert).not.toHaveBeenCalled()
|
||||
expect(user!.id).toBe(1)
|
||||
expect(user!.color).toBe(COLOR_PALETTE[0])
|
||||
})
|
||||
expect(mockDb.insert).not.toHaveBeenCalled();
|
||||
expect(user!.id).toBe(1);
|
||||
expect(user!.color).toBe(COLOR_PALETTE[0]);
|
||||
});
|
||||
|
||||
it('uses oidc_iss + oidc_sub as identity key, never email', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub = 'user-sub-003'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-003';
|
||||
|
||||
let selectCallCount = 0
|
||||
let selectCallCount = 0;
|
||||
mockDb.select.mockImplementation(() => {
|
||||
selectCallCount++
|
||||
if (selectCallCount === 1) return makeSelectChain([])
|
||||
selectCallCount++;
|
||||
if (selectCallCount === 1) return makeSelectChain([]);
|
||||
if (selectCallCount === 2) {
|
||||
return { from: vi.fn().mockResolvedValue([{ count: 0 }]) }
|
||||
return { from: vi.fn().mockResolvedValue([{ count: 0 }]) };
|
||||
}
|
||||
return makeSelectChain([
|
||||
{ id: 3, oidcIss: iss, oidcSub: sub, displayName: null, color: COLOR_PALETTE[0], createdAt: new Date() },
|
||||
])
|
||||
})
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 3 }]))
|
||||
{
|
||||
id: 3,
|
||||
oidcIss: iss,
|
||||
oidcSub: sub,
|
||||
displayName: null,
|
||||
color: COLOR_PALETTE[0],
|
||||
createdAt: new Date(),
|
||||
},
|
||||
]);
|
||||
});
|
||||
mockDb.insert.mockReturnValue(makeInsertChain([{ id: 3 }]));
|
||||
|
||||
// Pass a displayName (e.g. email) — identity still keyed on iss+sub
|
||||
await upsertUser(iss, sub, 'lucas@example.com')
|
||||
await upsertUser(iss, sub, 'lucas@example.com');
|
||||
|
||||
// The insert values must include oidcIss and oidcSub, not email as key
|
||||
const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0]
|
||||
expect(insertValues).toBeDefined()
|
||||
expect(insertValues.oidcIss).toBe(iss)
|
||||
expect(insertValues.oidcSub).toBe(sub)
|
||||
const insertValues = mockDb.insert.mock.results[0]?.value?.values.mock.calls[0]?.[0];
|
||||
expect(insertValues).toBeDefined();
|
||||
expect(insertValues.oidcIss).toBe(iss);
|
||||
expect(insertValues.oidcSub).toBe(sub);
|
||||
// No 'email' property should be used as an identity field
|
||||
expect(insertValues).not.toHaveProperty('email')
|
||||
})
|
||||
expect(insertValues).not.toHaveProperty('email');
|
||||
});
|
||||
|
||||
it('returns the full user row including id, color, displayName', async () => {
|
||||
const iss = 'https://auth.example.com'
|
||||
const sub = 'user-sub-004'
|
||||
const iss = 'https://auth.example.com';
|
||||
const sub = 'user-sub-004';
|
||||
const existingRow = {
|
||||
id: 42,
|
||||
oidcIss: iss,
|
||||
@@ -222,15 +250,15 @@ describe('upsertUser', () => {
|
||||
displayName: 'Alice',
|
||||
color: '#9B6DC5',
|
||||
createdAt: new Date(),
|
||||
}
|
||||
};
|
||||
|
||||
mockDb.select.mockImplementation(() => makeSelectChain([existingRow]))
|
||||
mockDb.select.mockImplementation(() => makeSelectChain([existingRow]));
|
||||
|
||||
const user = await upsertUser(iss, sub, 'Alice')
|
||||
const user = await upsertUser(iss, sub, 'Alice');
|
||||
|
||||
expect(user).toBeDefined()
|
||||
expect(user!.id).toBe(42)
|
||||
expect(user!.color).toBe('#9B6DC5')
|
||||
expect(user!.displayName).toBe('Alice')
|
||||
})
|
||||
})
|
||||
expect(user).toBeDefined();
|
||||
expect(user!.id).toBe(42);
|
||||
expect(user!.color).toBe('#9B6DC5');
|
||||
expect(user!.displayName).toBe('Alice');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,72 +9,72 @@
|
||||
* - Stored payload shape
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeAll } from 'vitest'
|
||||
import { describe, it, expect, beforeAll } from 'vitest';
|
||||
|
||||
// Set a fixed 32-byte (64-char hex) key before importing the module
|
||||
const TEST_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2'
|
||||
const TEST_KEY = 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2';
|
||||
|
||||
beforeAll(() => {
|
||||
process.env.APP_PASSWORD_ENCRYPTION_KEY = TEST_KEY
|
||||
})
|
||||
process.env.APP_PASSWORD_ENCRYPTION_KEY = TEST_KEY;
|
||||
});
|
||||
|
||||
// Dynamic import so env is set before module-level KEY evaluation
|
||||
async function getCrypto() {
|
||||
return import('../../src/broker/crypto.js')
|
||||
return import('../../src/broker/crypto.js');
|
||||
}
|
||||
|
||||
describe('encryptPassword / decryptPassword', () => {
|
||||
it('roundtrip: decrypt(encrypt(plaintext)) === plaintext', async () => {
|
||||
const { encryptPassword, decryptPassword } = await getCrypto()
|
||||
const plaintext = 'my-fastmail-app-password-abc123'
|
||||
const encrypted = encryptPassword(plaintext)
|
||||
expect(decryptPassword(encrypted)).toBe(plaintext)
|
||||
})
|
||||
const { encryptPassword, decryptPassword } = await getCrypto();
|
||||
const plaintext = 'my-fastmail-app-password-abc123';
|
||||
const encrypted = encryptPassword(plaintext);
|
||||
expect(decryptPassword(encrypted)).toBe(plaintext);
|
||||
});
|
||||
|
||||
it('different IVs produce different ciphertext for the same plaintext', async () => {
|
||||
const { encryptPassword } = await getCrypto()
|
||||
const plaintext = 'same-password'
|
||||
const enc1 = encryptPassword(plaintext)
|
||||
const enc2 = encryptPassword(plaintext)
|
||||
const { encryptPassword } = await getCrypto();
|
||||
const plaintext = 'same-password';
|
||||
const enc1 = encryptPassword(plaintext);
|
||||
const enc2 = encryptPassword(plaintext);
|
||||
// The JSON payloads must differ (different IVs → different ciphertext)
|
||||
expect(enc1).not.toBe(enc2)
|
||||
expect(enc1).not.toBe(enc2);
|
||||
// And the IVs themselves must differ
|
||||
const p1 = JSON.parse(enc1)
|
||||
const p2 = JSON.parse(enc2)
|
||||
expect(p1.iv).not.toBe(p2.iv)
|
||||
})
|
||||
const p1 = JSON.parse(enc1);
|
||||
const p2 = JSON.parse(enc2);
|
||||
expect(p1.iv).not.toBe(p2.iv);
|
||||
});
|
||||
|
||||
it('decrypting with a tampered authTag throws', async () => {
|
||||
const { encryptPassword, decryptPassword } = await getCrypto()
|
||||
const encrypted = encryptPassword('secret')
|
||||
const payload = JSON.parse(encrypted)
|
||||
const { encryptPassword, decryptPassword } = await getCrypto();
|
||||
const encrypted = encryptPassword('secret');
|
||||
const payload = JSON.parse(encrypted);
|
||||
// Flip first byte of authTag
|
||||
payload.authTag = ('ff' + payload.authTag.slice(2))
|
||||
expect(() => decryptPassword(JSON.stringify(payload))).toThrow()
|
||||
})
|
||||
payload.authTag = 'ff' + payload.authTag.slice(2);
|
||||
expect(() => decryptPassword(JSON.stringify(payload))).toThrow();
|
||||
});
|
||||
|
||||
it('decrypting with a tampered ciphertext throws', async () => {
|
||||
const { encryptPassword, decryptPassword } = await getCrypto()
|
||||
const encrypted = encryptPassword('secret')
|
||||
const payload = JSON.parse(encrypted)
|
||||
const { encryptPassword, decryptPassword } = await getCrypto();
|
||||
const encrypted = encryptPassword('secret');
|
||||
const payload = JSON.parse(encrypted);
|
||||
// Flip first byte of ciphertext
|
||||
payload.ciphertext = ('ff' + payload.ciphertext.slice(2))
|
||||
expect(() => decryptPassword(JSON.stringify(payload))).toThrow()
|
||||
})
|
||||
payload.ciphertext = 'ff' + payload.ciphertext.slice(2);
|
||||
expect(() => decryptPassword(JSON.stringify(payload))).toThrow();
|
||||
});
|
||||
|
||||
it('stored payload is valid JSON with iv, authTag, ciphertext fields', async () => {
|
||||
const { encryptPassword } = await getCrypto()
|
||||
const encrypted = encryptPassword('test-password')
|
||||
const payload = JSON.parse(encrypted)
|
||||
expect(payload).toHaveProperty('iv')
|
||||
expect(payload).toHaveProperty('authTag')
|
||||
expect(payload).toHaveProperty('ciphertext')
|
||||
const { encryptPassword } = await getCrypto();
|
||||
const encrypted = encryptPassword('test-password');
|
||||
const payload = JSON.parse(encrypted);
|
||||
expect(payload).toHaveProperty('iv');
|
||||
expect(payload).toHaveProperty('authTag');
|
||||
expect(payload).toHaveProperty('ciphertext');
|
||||
// All values are non-empty hex strings
|
||||
expect(typeof payload.iv).toBe('string')
|
||||
expect(payload.iv.length).toBeGreaterThan(0)
|
||||
expect(typeof payload.authTag).toBe('string')
|
||||
expect(payload.authTag.length).toBeGreaterThan(0)
|
||||
expect(typeof payload.ciphertext).toBe('string')
|
||||
expect(payload.ciphertext.length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
expect(typeof payload.iv).toBe('string');
|
||||
expect(payload.iv.length).toBeGreaterThan(0);
|
||||
expect(typeof payload.authTag).toBe('string');
|
||||
expect(payload.authTag.length).toBeGreaterThan(0);
|
||||
expect(typeof payload.ciphertext).toBe('string');
|
||||
expect(payload.ciphertext.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,23 +13,22 @@
|
||||
* duration derives from DTSTART→DTEND (not the recurrence span) (D-06 invariant).
|
||||
*/
|
||||
|
||||
import 'temporal-polyfill/global'
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { readFileSync } from 'fs'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import 'temporal-polyfill/global';
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { readFileSync } from 'fs';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { expandOccurrences } from '../../src/broker/expand.js'
|
||||
import { expandOccurrences } from '../../src/broker/expand.js';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const FIXTURES = join(__dirname, '../fixtures')
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const FIXTURES = join(__dirname, '../fixtures');
|
||||
|
||||
function loadFixture(name: string): string {
|
||||
return readFileSync(join(FIXTURES, name), 'utf8')
|
||||
return readFileSync(join(FIXTURES, name), 'utf8');
|
||||
}
|
||||
|
||||
describe('expandOccurrences', () => {
|
||||
|
||||
describe('DST correctness — weekly-dst.ics', () => {
|
||||
it('returns 10:00 America/New_York wall-clock time on BOTH sides of March 2026 DST boundary', () => {
|
||||
// The fixture has DTSTART;TZID=America/New_York:20260301T100000 RRULE:FREQ=WEEKLY.
|
||||
@@ -38,62 +37,62 @@ describe('expandOccurrences', () => {
|
||||
// 2026-03-15 (EDT, UTC-4) must ALL show hour === 10 in America/New_York.
|
||||
// A broken implementation that falls back to UTC would show hour === 10 UTC before
|
||||
// transition and hour === 11 local after transition — off by one DST hour.
|
||||
const rawVevent = loadFixture('weekly-dst.ics')
|
||||
const windowStart = new Date('2026-03-01T00:00:00Z')
|
||||
const windowEnd = new Date('2026-04-01T00:00:00Z')
|
||||
const rawVevent = loadFixture('weekly-dst.ics');
|
||||
const windowStart = new Date('2026-03-01T00:00:00Z');
|
||||
const windowEnd = new Date('2026-04-01T00:00:00Z');
|
||||
|
||||
const occurrences = expandOccurrences(
|
||||
rawVevent,
|
||||
windowStart,
|
||||
windowEnd,
|
||||
1, // calendarId
|
||||
1, // calendarId
|
||||
'My Calendar', // calendarName
|
||||
1, // ownerUserId
|
||||
'Alice', // ownerName
|
||||
'#4A90D9', // color
|
||||
false, // isShared
|
||||
)
|
||||
1, // ownerUserId
|
||||
'Alice', // ownerName
|
||||
'#4A90D9', // color
|
||||
false, // isShared
|
||||
);
|
||||
|
||||
// Should return several weekly occurrences in March
|
||||
expect(occurrences.length).toBeGreaterThan(0)
|
||||
expect(occurrences.length).toBeGreaterThan(0);
|
||||
|
||||
// The key contract: every occurrence must have local hour === 10 in America/New_York.
|
||||
// We verify this by checking the ISO string — before DST: '...T10:00:00-05:00[America/New_York]'
|
||||
// after DST: '...T10:00:00-04:00[America/New_York]'. Both include the IANA bracket.
|
||||
for (const occ of occurrences) {
|
||||
expect(occ.allDay).toBe(false)
|
||||
expect(occ.allDay).toBe(false);
|
||||
// ownerName must be threaded through to every occurrence
|
||||
expect(occ.ownerName).toBe('Alice')
|
||||
expect(occ.ownerName).toBe('Alice');
|
||||
// start must be IANA-annotated: '2026-03-01T10:00:00-05:00[America/New_York]'
|
||||
expect(occ.start).toMatch(/T10:00:00/)
|
||||
expect(occ.start).toMatch(/T10:00:00/);
|
||||
// Must include IANA bracket — offset-only strings fail Temporal.ZonedDateTime.from()
|
||||
expect(occ.start).toContain('[America/New_York]')
|
||||
expect(occ.start).toContain('[America/New_York]');
|
||||
}
|
||||
|
||||
// Explicitly check one pre-transition occurrence (EST) and one post-transition (EDT)
|
||||
const preTransition = occurrences.find(o => o.start.includes('2026-03-01'))
|
||||
const postTransition = occurrences.find(o => o.start.includes('2026-03-15'))
|
||||
const preTransition = occurrences.find((o) => o.start.includes('2026-03-01'));
|
||||
const postTransition = occurrences.find((o) => o.start.includes('2026-03-15'));
|
||||
|
||||
expect(preTransition).toBeDefined()
|
||||
expect(postTransition).toBeDefined()
|
||||
expect(preTransition).toBeDefined();
|
||||
expect(postTransition).toBeDefined();
|
||||
|
||||
// Pre-transition occurrence: EST — '2026-03-01T10:00:00-05:00[America/New_York]'
|
||||
expect(preTransition!.start).toContain('T10:00:00')
|
||||
expect(preTransition!.start).toContain('-05:00[America/New_York]')
|
||||
expect(preTransition!.start).toContain('T10:00:00');
|
||||
expect(preTransition!.start).toContain('-05:00[America/New_York]');
|
||||
// Post-transition occurrence: EDT — '2026-03-15T10:00:00-04:00[America/New_York]'
|
||||
expect(postTransition!.start).toContain('T10:00:00')
|
||||
expect(postTransition!.start).toContain('-04:00[America/New_York]')
|
||||
})
|
||||
})
|
||||
expect(postTransition!.start).toContain('T10:00:00');
|
||||
expect(postTransition!.start).toContain('-04:00[America/New_York]');
|
||||
});
|
||||
});
|
||||
|
||||
describe('All-day events — allday-birthday.ics', () => {
|
||||
it('returns allDay:true with start as YYYY-MM-DD and no time component', () => {
|
||||
// The fixture has DTSTART;VALUE=DATE:20260615 with RRULE:FREQ=YEARLY.
|
||||
// The all-day occurrence should have allDay:true and start === '2026-06-15' (DATE format).
|
||||
// A broken implementation returning '2026-06-15T00:00:00Z' would fail on date-shift.
|
||||
const rawVevent = loadFixture('allday-birthday.ics')
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z')
|
||||
const windowEnd = new Date('2026-07-01T00:00:00Z')
|
||||
const rawVevent = loadFixture('allday-birthday.ics');
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z');
|
||||
const windowEnd = new Date('2026-07-01T00:00:00Z');
|
||||
|
||||
const occurrences = expandOccurrences(
|
||||
rawVevent,
|
||||
@@ -102,20 +101,20 @@ describe('expandOccurrences', () => {
|
||||
1,
|
||||
'My Calendar',
|
||||
1,
|
||||
null, // ownerName
|
||||
null, // ownerName
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
);
|
||||
|
||||
expect(occurrences.length).toBe(1)
|
||||
expect(occurrences.length).toBe(1);
|
||||
|
||||
const occ = occurrences[0]
|
||||
expect(occ.allDay).toBe(true)
|
||||
const occ = occurrences[0];
|
||||
expect(occ.allDay).toBe(true);
|
||||
// start must be plain date string 'YYYY-MM-DD' — no 'T' time component
|
||||
expect(occ.start).toBe('2026-06-15')
|
||||
expect(occ.start).not.toContain('T')
|
||||
})
|
||||
})
|
||||
expect(occ.start).toBe('2026-06-15');
|
||||
expect(occ.start).not.toContain('T');
|
||||
});
|
||||
});
|
||||
|
||||
describe('EXDATE exclusions — exdate-series.ics', () => {
|
||||
it('omits the EXDATE-excluded occurrence (array length is one fewer than un-excluded)', () => {
|
||||
@@ -123,9 +122,9 @@ describe('expandOccurrences', () => {
|
||||
// Without EXDATE: 5 occurrences (Jun 1, Jun 8, Jun 15, Jun 22, Jun 29).
|
||||
// With EXDATE on Jun 15: 4 occurrences returned.
|
||||
// ICAL.RecurExpansion handles EXDATE internally — no manual filtering needed.
|
||||
const rawVevent = loadFixture('exdate-series.ics')
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z')
|
||||
const windowEnd = new Date('2026-07-01T00:00:00Z')
|
||||
const rawVevent = loadFixture('exdate-series.ics');
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z');
|
||||
const windowEnd = new Date('2026-07-01T00:00:00Z');
|
||||
|
||||
const occurrences = expandOccurrences(
|
||||
rawVevent,
|
||||
@@ -134,28 +133,28 @@ describe('expandOccurrences', () => {
|
||||
1,
|
||||
'My Calendar',
|
||||
1,
|
||||
null, // ownerName
|
||||
null, // ownerName
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
);
|
||||
|
||||
// 5 total occurrences minus 1 EXDATE = 4
|
||||
expect(occurrences.length).toBe(4)
|
||||
expect(occurrences.length).toBe(4);
|
||||
|
||||
// The June 15 occurrence must be absent
|
||||
const june15 = occurrences.find(o => o.start.includes('2026-06-15'))
|
||||
expect(june15).toBeUndefined()
|
||||
})
|
||||
})
|
||||
const june15 = occurrences.find((o) => o.start.includes('2026-06-15'));
|
||||
expect(june15).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Non-recurring DURATION-only event — single-duration.ics', () => {
|
||||
it('BUG-1 regression: non-recurring event with DURATION but no DTEND has end strictly after start', () => {
|
||||
// Real Fastmail events use DURATION (not DTEND). Before the fix, the NON-RECURRING branch
|
||||
// used getFirstPropertyValue('dtend') ?? dtstart, which returned dtstart when DTEND was absent,
|
||||
// producing zero-duration occurrences (invisible in week/day views).
|
||||
const rawVevent = loadFixture('single-duration.ics')
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z')
|
||||
const windowEnd = new Date('2026-07-01T00:00:00Z')
|
||||
const rawVevent = loadFixture('single-duration.ics');
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z');
|
||||
const windowEnd = new Date('2026-07-01T00:00:00Z');
|
||||
|
||||
const occurrences = expandOccurrences(
|
||||
rawVevent,
|
||||
@@ -164,33 +163,33 @@ describe('expandOccurrences', () => {
|
||||
1,
|
||||
'My Calendar',
|
||||
1,
|
||||
null, // ownerName
|
||||
null, // ownerName
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
);
|
||||
|
||||
expect(occurrences.length).toBe(1)
|
||||
expect(occurrences.length).toBe(1);
|
||||
|
||||
const occ = occurrences[0]
|
||||
expect(occ.allDay).toBe(false)
|
||||
const occ = occurrences[0];
|
||||
expect(occ.allDay).toBe(false);
|
||||
|
||||
// Parse both via Temporal.ZonedDateTime and assert end > start
|
||||
const startZdt = Temporal.ZonedDateTime.from(occ.start)
|
||||
const endZdt = Temporal.ZonedDateTime.from(occ.end)
|
||||
expect(Temporal.ZonedDateTime.compare(endZdt, startZdt)).toBeGreaterThan(0)
|
||||
const startZdt = Temporal.ZonedDateTime.from(occ.start);
|
||||
const endZdt = Temporal.ZonedDateTime.from(occ.end);
|
||||
expect(Temporal.ZonedDateTime.compare(endZdt, startZdt)).toBeGreaterThan(0);
|
||||
|
||||
// Verify the actual duration is correct: DURATION:PT1H → end is 1 hour after start
|
||||
expect(endZdt.hour - startZdt.hour).toBe(1)
|
||||
expect(endZdt.minute).toBe(startZdt.minute)
|
||||
})
|
||||
})
|
||||
expect(endZdt.hour - startZdt.hour).toBe(1);
|
||||
expect(endZdt.minute).toBe(startZdt.minute);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasRrule field — D-08 (recurring series detection)', () => {
|
||||
it('recurring event occurrence has hasRrule === true', () => {
|
||||
// weekly-dst.ics has RRULE:FREQ=WEEKLY — all occurrences must have hasRrule === true
|
||||
const rawVevent = loadFixture('weekly-dst.ics')
|
||||
const windowStart = new Date('2026-03-01T00:00:00Z')
|
||||
const windowEnd = new Date('2026-04-01T00:00:00Z')
|
||||
const rawVevent = loadFixture('weekly-dst.ics');
|
||||
const windowStart = new Date('2026-03-01T00:00:00Z');
|
||||
const windowEnd = new Date('2026-04-01T00:00:00Z');
|
||||
|
||||
const occurrences = expandOccurrences(
|
||||
rawVevent,
|
||||
@@ -202,19 +201,19 @@ describe('expandOccurrences', () => {
|
||||
null,
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
);
|
||||
|
||||
expect(occurrences.length).toBeGreaterThan(0)
|
||||
expect(occurrences.length).toBeGreaterThan(0);
|
||||
for (const occ of occurrences) {
|
||||
expect(occ.hasRrule).toBe(true)
|
||||
expect(occ.hasRrule).toBe(true);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('non-recurring event occurrence has hasRrule === false', () => {
|
||||
// single-duration.ics has no RRULE — the single occurrence must have hasRrule === false
|
||||
const rawVevent = loadFixture('single-duration.ics')
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z')
|
||||
const windowEnd = new Date('2026-07-01T00:00:00Z')
|
||||
const rawVevent = loadFixture('single-duration.ics');
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z');
|
||||
const windowEnd = new Date('2026-07-01T00:00:00Z');
|
||||
|
||||
const occurrences = expandOccurrences(
|
||||
rawVevent,
|
||||
@@ -226,20 +225,20 @@ describe('expandOccurrences', () => {
|
||||
null,
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
);
|
||||
|
||||
expect(occurrences.length).toBe(1)
|
||||
expect(occurrences[0].hasRrule).toBe(false)
|
||||
})
|
||||
})
|
||||
expect(occurrences.length).toBe(1);
|
||||
expect(occurrences[0].hasRrule).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bounded RRULE (COUNT=3) — D-06 invariant', () => {
|
||||
it('expands to exactly 3 occurrences within a wide window (COUNT terminates expansion)', () => {
|
||||
// weekly-count3.ics has RRULE:FREQ=WEEKLY;COUNT=3 starting 2026-06-01.
|
||||
// A 6-month window must return exactly 3 occurrences — not more.
|
||||
const rawVevent = loadFixture('weekly-count3.ics')
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z')
|
||||
const windowEnd = new Date('2026-12-01T00:00:00Z')
|
||||
const rawVevent = loadFixture('weekly-count3.ics');
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z');
|
||||
const windowEnd = new Date('2026-12-01T00:00:00Z');
|
||||
|
||||
const occurrences = expandOccurrences(
|
||||
rawVevent,
|
||||
@@ -251,17 +250,17 @@ describe('expandOccurrences', () => {
|
||||
null,
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
);
|
||||
|
||||
expect(occurrences.length).toBe(3)
|
||||
})
|
||||
expect(occurrences.length).toBe(3);
|
||||
});
|
||||
|
||||
it('each bounded occurrence duration derives from DTSTART→DTEND (1 hour), not recurrence span', () => {
|
||||
// Each occurrence must have end exactly 1 hour after start.
|
||||
// The recurrence span is many weeks; duration must be per-event, not recurrence-level.
|
||||
const rawVevent = loadFixture('weekly-count3.ics')
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z')
|
||||
const windowEnd = new Date('2026-12-01T00:00:00Z')
|
||||
const rawVevent = loadFixture('weekly-count3.ics');
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z');
|
||||
const windowEnd = new Date('2026-12-01T00:00:00Z');
|
||||
|
||||
const occurrences = expandOccurrences(
|
||||
rawVevent,
|
||||
@@ -273,23 +272,23 @@ describe('expandOccurrences', () => {
|
||||
null,
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
);
|
||||
|
||||
expect(occurrences.length).toBe(3)
|
||||
expect(occurrences.length).toBe(3);
|
||||
for (const occ of occurrences) {
|
||||
const startZdt = Temporal.ZonedDateTime.from(occ.start)
|
||||
const endZdt = Temporal.ZonedDateTime.from(occ.end)
|
||||
const startZdt = Temporal.ZonedDateTime.from(occ.start);
|
||||
const endZdt = Temporal.ZonedDateTime.from(occ.end);
|
||||
// Each occurrence must be exactly 1 hour (3 600 000 ms).
|
||||
// Use epochMilliseconds which is a regular number in the polyfill.
|
||||
const durationMs = endZdt.epochMilliseconds - startZdt.epochMilliseconds
|
||||
expect(durationMs).toBe(3_600_000)
|
||||
const durationMs = endZdt.epochMilliseconds - startZdt.epochMilliseconds;
|
||||
expect(durationMs).toBe(3_600_000);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('bounded occurrences have hasRrule === true', () => {
|
||||
const rawVevent = loadFixture('weekly-count3.ics')
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z')
|
||||
const windowEnd = new Date('2026-12-01T00:00:00Z')
|
||||
const rawVevent = loadFixture('weekly-count3.ics');
|
||||
const windowStart = new Date('2026-06-01T00:00:00Z');
|
||||
const windowEnd = new Date('2026-12-01T00:00:00Z');
|
||||
|
||||
const occurrences = expandOccurrences(
|
||||
rawVevent,
|
||||
@@ -301,14 +300,14 @@ describe('expandOccurrences', () => {
|
||||
null,
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
);
|
||||
|
||||
expect(occurrences.length).toBe(3)
|
||||
expect(occurrences.length).toBe(3);
|
||||
for (const occ of occurrences) {
|
||||
expect(occ.hasRrule).toBe(true)
|
||||
expect(occ.hasRrule).toBe(true);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cross-contract: expand output → Temporal.ZonedDateTime.from (regression guard)', () => {
|
||||
it('timed event start/end strings from weekly-dst.ics parse via Temporal.ZonedDateTime.from without throwing', () => {
|
||||
@@ -319,9 +318,9 @@ describe('expandOccurrences', () => {
|
||||
// Previously, serializeTime emitted offset-only strings like '2026-03-01T10:00:00-05:00'
|
||||
// which caused Temporal.ZonedDateTime.from() to throw RangeError: Cannot parse.
|
||||
// Now it emits IANA-annotated strings like '2026-03-01T10:00:00-05:00[America/New_York]'.
|
||||
const rawVevent = loadFixture('weekly-dst.ics')
|
||||
const windowStart = new Date('2026-03-01T00:00:00Z')
|
||||
const windowEnd = new Date('2026-04-01T00:00:00Z')
|
||||
const rawVevent = loadFixture('weekly-dst.ics');
|
||||
const windowStart = new Date('2026-03-01T00:00:00Z');
|
||||
const windowEnd = new Date('2026-04-01T00:00:00Z');
|
||||
|
||||
const occurrences = expandOccurrences(
|
||||
rawVevent,
|
||||
@@ -330,23 +329,23 @@ describe('expandOccurrences', () => {
|
||||
1,
|
||||
'My Calendar',
|
||||
1,
|
||||
null, // ownerName
|
||||
null, // ownerName
|
||||
'#4A90D9',
|
||||
false,
|
||||
)
|
||||
);
|
||||
|
||||
expect(occurrences.length).toBeGreaterThan(0)
|
||||
expect(occurrences.length).toBeGreaterThan(0);
|
||||
|
||||
for (const occ of occurrences) {
|
||||
// These must not throw — this is the cross-service contract
|
||||
expect(() => Temporal.ZonedDateTime.from(occ.start)).not.toThrow()
|
||||
expect(() => Temporal.ZonedDateTime.from(occ.end)).not.toThrow()
|
||||
expect(() => Temporal.ZonedDateTime.from(occ.start)).not.toThrow();
|
||||
expect(() => Temporal.ZonedDateTime.from(occ.end)).not.toThrow();
|
||||
|
||||
// Parsed ZonedDateTime must round-trip the wall-clock hour
|
||||
const startZdt = Temporal.ZonedDateTime.from(occ.start)
|
||||
expect(startZdt.hour).toBe(10)
|
||||
expect(startZdt.timeZoneId).toBe('America/New_York')
|
||||
const startZdt = Temporal.ZonedDateTime.from(occ.start);
|
||||
expect(startZdt.hour).toBe(10);
|
||||
expect(startZdt.timeZoneId).toBe('America/New_York');
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
* They will turn GREEN in Plan 03-03 when the implementation is added.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// This import fails (RED) — broker/outboxWorker.ts does not exist yet.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore intentional RED import
|
||||
import { runOutboxDrain, assembleRruleString } from '../../src/broker/outboxWorker.js'
|
||||
import { runOutboxDrain, assembleRruleString } from '../../src/broker/outboxWorker.js';
|
||||
|
||||
// ── Drizzle DB mock ────────────────────────────────────────────────────────
|
||||
// Follows the pattern from PATTERNS.md §Drizzle DB mock in tests.
|
||||
@@ -37,60 +37,68 @@ const {
|
||||
mockSelectFn,
|
||||
mockDecryptPassword,
|
||||
} = vi.hoisted(() => {
|
||||
const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
||||
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet })
|
||||
const mockUpdateSet = vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
|
||||
const mockUpdate = vi.fn().mockReturnValue({ set: mockUpdateSet });
|
||||
// mockWherePending: terminal node for calendarOutbox selects (pending-rows + sibling-status)
|
||||
// db.select().from(calendarOutbox).where(...) — resolves to the row array
|
||||
const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
|
||||
const mockWherePending = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]));
|
||||
// mockWhereCalEvents: terminal node for calendarEvents selects (etag re-read for WR-02)
|
||||
// db.select({etag}).from(calendarEvents).where(...) — resolves to the etag array
|
||||
const mockWhereCalEvents = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]))
|
||||
const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending })
|
||||
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn })
|
||||
const mockWhereCalEvents = vi.fn().mockImplementation(() => Promise.resolve([] as unknown[]));
|
||||
const mockFromFn = vi.fn().mockReturnValue({ where: mockWherePending });
|
||||
const mockSelectFn = vi.fn().mockReturnValue({ from: mockFromFn });
|
||||
// By default returns a dummy password so loadClientForUser succeeds
|
||||
const mockDecryptPassword = vi.fn().mockReturnValue('app-password')
|
||||
return { mockUpdateSet, mockUpdate, mockWherePending, mockWhereCalEvents, mockFromFn, mockSelectFn, mockDecryptPassword }
|
||||
})
|
||||
const mockDecryptPassword = vi.fn().mockReturnValue('app-password');
|
||||
return {
|
||||
mockUpdateSet,
|
||||
mockUpdate,
|
||||
mockWherePending,
|
||||
mockWhereCalEvents,
|
||||
mockFromFn,
|
||||
mockSelectFn,
|
||||
mockDecryptPassword,
|
||||
};
|
||||
});
|
||||
|
||||
let mockPendingRows: unknown[] = []
|
||||
let mockPendingRows: unknown[] = [];
|
||||
|
||||
// Fake credential row returned by loadClientForUser's db.select().from(memberCredentials).where()
|
||||
const FAKE_CRED_ROW = {
|
||||
userId: 42,
|
||||
fastmailEmail: 'test@fastmail.com',
|
||||
encryptedPassword: '{"iv":"aa","authTag":"bb","ciphertext":"cc"}',
|
||||
}
|
||||
};
|
||||
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
db: {
|
||||
select: mockSelectFn,
|
||||
update: mockUpdate,
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock write functions — these are called by outboxWorker for the actual CalDAV ops
|
||||
vi.mock('../../src/broker/write.js', () => ({
|
||||
createCalendarEvent: vi.fn(),
|
||||
updateCalendarEvent: vi.fn(),
|
||||
deleteCalendarEvent: vi.fn(),
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock sync — called after successful write (D-06)
|
||||
vi.mock('../../src/broker/sync.js', () => ({
|
||||
syncCalendar: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock client creation — the worker needs a DAVClient to call sync
|
||||
vi.mock('../../src/broker/client.js', () => ({
|
||||
createFastmailClient: vi.fn().mockResolvedValue({
|
||||
fetchCalendars: vi.fn().mockResolvedValue([]),
|
||||
}),
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock crypto — controls whether loadClientForUser succeeds or throws (CR-03 tests)
|
||||
vi.mock('../../src/broker/crypto.js', () => ({
|
||||
decryptPassword: mockDecryptPassword,
|
||||
}))
|
||||
}));
|
||||
|
||||
// Default payload is form JSON (the worker must build ICS from this — not pass raw JSON to CalDAV)
|
||||
const DEFAULT_FORM_PAYLOAD = JSON.stringify({
|
||||
@@ -99,7 +107,7 @@ const DEFAULT_FORM_PAYLOAD = JSON.stringify({
|
||||
start: '2026-06-10T12:00:00',
|
||||
end: '2026-06-10T13:00:00',
|
||||
recurrence: 'none',
|
||||
})
|
||||
});
|
||||
|
||||
const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 1,
|
||||
@@ -118,25 +126,25 @@ const makeRow = (overrides: Record<string, unknown> = {}) => ({
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
...overrides,
|
||||
})
|
||||
});
|
||||
|
||||
const makeResponse = (status: number): Response =>
|
||||
({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response
|
||||
({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response;
|
||||
|
||||
// Helper: wire db mock so outbox queries return mockPendingRows and credential queries return FAKE_CRED_ROW
|
||||
// This is called in each beforeEach after vi.clearAllMocks() to restore the mock chain.
|
||||
function wireMockChain() {
|
||||
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) })
|
||||
mockUpdate.mockReturnValue({ set: mockUpdateSet })
|
||||
mockUpdateSet.mockReturnValue({ where: vi.fn().mockResolvedValue(undefined) });
|
||||
mockUpdate.mockReturnValue({ set: mockUpdateSet });
|
||||
// mockFromFn differentiates by table argument using Symbol.for('drizzle:Name'):
|
||||
// - memberCredentials → returns FAKE_CRED_ROW (so loadClientForUser succeeds by default)
|
||||
// - calendarEvents → returns mockWhereCalEvents (etag re-read for WR-02)
|
||||
// - calendarOutbox (and anything else) → returns mockWherePending (pending-rows + sibling-status)
|
||||
// JSON.stringify throws on circular Drizzle table structures; use Symbol identity instead.
|
||||
mockFromFn.mockImplementation((table: unknown) => {
|
||||
const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? ''
|
||||
const tableName = (table as Record<symbol, string>)[Symbol.for('drizzle:Name')] ?? '';
|
||||
if (tableName === 'member_credentials') {
|
||||
return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) }
|
||||
return { where: vi.fn().mockResolvedValue([FAKE_CRED_ROW]) };
|
||||
}
|
||||
if (tableName === 'calendar_events') {
|
||||
// CR-02: the freshest-etag re-read now scopes to the writing member's calendar:
|
||||
@@ -147,174 +155,176 @@ function wireMockChain() {
|
||||
innerJoin: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({ limit: mockWhereCalEvents }),
|
||||
}),
|
||||
}
|
||||
};
|
||||
}
|
||||
return { where: mockWherePending }
|
||||
})
|
||||
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]))
|
||||
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows))
|
||||
mockSelectFn.mockReturnValue({ from: mockFromFn })
|
||||
return { where: mockWherePending };
|
||||
});
|
||||
mockWhereCalEvents.mockImplementation(() => Promise.resolve([]));
|
||||
mockWherePending.mockImplementation(() => Promise.resolve(mockPendingRows));
|
||||
mockSelectFn.mockReturnValue({ from: mockFromFn });
|
||||
// Default: decryptPassword succeeds
|
||||
mockDecryptPassword.mockReturnValue('app-password')
|
||||
mockDecryptPassword.mockReturnValue('app-password');
|
||||
}
|
||||
|
||||
describe('runOutboxDrain — state transitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
vi.resetAllMocks();
|
||||
mockPendingRows = [];
|
||||
wireMockChain();
|
||||
});
|
||||
|
||||
it('transitions pending→done on 204 response and triggers re-sync (D-06)', async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(204))
|
||||
mockPendingRows = [makeRow()]
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(204));
|
||||
mockPendingRows = [makeRow()];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// Must update status to 'done'
|
||||
expect(mockUpdate).toHaveBeenCalled()
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
|
||||
expect(setArg?.status).toBe('done')
|
||||
})
|
||||
expect(mockUpdate).toHaveBeenCalled();
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string };
|
||||
expect(setArg?.status).toBe('done');
|
||||
});
|
||||
|
||||
it('transitions pending→failed on 412 (conflict — no retry), marks failed (D-08)', async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(412))
|
||||
mockPendingRows = [makeRow()]
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(412));
|
||||
mockPendingRows = [makeRow()];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// 412 = hard fail (conflict) — must NOT retry, must mark failed
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string }
|
||||
expect(setArg?.status).toBe('failed')
|
||||
expect(setArg?.lastError).toBeTruthy()
|
||||
})
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string };
|
||||
expect(setArg?.status).toBe('failed');
|
||||
expect(setArg?.lastError).toBeTruthy();
|
||||
});
|
||||
|
||||
it('transitions pending→backoff (attemptCount++, nextAttemptAt advanced) on 500 (transient)', async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500))
|
||||
const row = makeRow({ attemptCount: 0 })
|
||||
mockPendingRows = [row]
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500));
|
||||
const row = makeRow({ attemptCount: 0 });
|
||||
mockPendingRows = [row];
|
||||
|
||||
const beforeDrain = Date.now()
|
||||
await runOutboxDrain()
|
||||
const beforeDrain = Date.now();
|
||||
await runOutboxDrain();
|
||||
|
||||
// Must NOT transition to done or failed — backoff
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as {
|
||||
status?: string
|
||||
attemptCount?: number
|
||||
nextAttemptAt?: Date
|
||||
}
|
||||
expect(setArg?.status).not.toBe('done')
|
||||
expect(setArg?.status).not.toBe('failed')
|
||||
expect(setArg?.attemptCount).toBe(1)
|
||||
status?: string;
|
||||
attemptCount?: number;
|
||||
nextAttemptAt?: Date;
|
||||
};
|
||||
expect(setArg?.status).not.toBe('done');
|
||||
expect(setArg?.status).not.toBe('failed');
|
||||
expect(setArg?.attemptCount).toBe(1);
|
||||
// nextAttemptAt must be in the future
|
||||
expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThan(beforeDrain)
|
||||
})
|
||||
expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThan(beforeDrain);
|
||||
});
|
||||
|
||||
it('transitions pending→dead when attemptCount reaches MAX_ATTEMPTS on transient error', async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500))
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500));
|
||||
// MAX_ATTEMPTS is 5 per RESEARCH.md Pattern 4 — at attempt 4 (0-indexed) → dead
|
||||
const row = makeRow({ attemptCount: 4 })
|
||||
mockPendingRows = [row]
|
||||
const row = makeRow({ attemptCount: 4 });
|
||||
mockPendingRows = [row];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
|
||||
expect(setArg?.status).toBe('dead')
|
||||
})
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string };
|
||||
expect(setArg?.status).toBe('dead');
|
||||
});
|
||||
|
||||
it('does not crash when pending rows list is empty', async () => {
|
||||
mockPendingRows = []
|
||||
await expect(runOutboxDrain()).resolves.not.toThrow()
|
||||
})
|
||||
})
|
||||
mockPendingRows = [];
|
||||
await expect(runOutboxDrain()).resolves.not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
vi.resetAllMocks();
|
||||
mockPendingRows = [];
|
||||
wireMockChain();
|
||||
});
|
||||
|
||||
it('create row: icsString passed to createCalendarEvent starts with BEGIN:VCALENDAR and contains SUMMARY', async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
let capturedIcsString: unknown = null
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
let capturedIcsString: unknown = null;
|
||||
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
|
||||
capturedIcsString = icsString
|
||||
return makeResponse(201)
|
||||
})
|
||||
mockPendingRows = [makeRow()]
|
||||
capturedIcsString = icsString;
|
||||
return makeResponse(201);
|
||||
});
|
||||
mockPendingRows = [makeRow()];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(typeof capturedIcsString).toBe('string')
|
||||
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true)
|
||||
expect(capturedIcsString).toContain('SUMMARY:Lunch')
|
||||
})
|
||||
expect(typeof capturedIcsString).toBe('string');
|
||||
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true);
|
||||
expect(capturedIcsString).toContain('SUMMARY:Lunch');
|
||||
});
|
||||
|
||||
it('update row: icsString passed to updateCalendarEvent starts with BEGIN:VCALENDAR', async () => {
|
||||
const { updateCalendarEvent } = await import('../../src/broker/write.js')
|
||||
let capturedIcsString: unknown = null
|
||||
const { updateCalendarEvent } = await import('../../src/broker/write.js');
|
||||
let capturedIcsString: unknown = null;
|
||||
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, icsString, _etag) => {
|
||||
capturedIcsString = icsString
|
||||
return makeResponse(204)
|
||||
})
|
||||
mockPendingRows = [makeRow({
|
||||
operation: 'update',
|
||||
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
|
||||
})]
|
||||
capturedIcsString = icsString;
|
||||
return makeResponse(204);
|
||||
});
|
||||
mockPendingRows = [
|
||||
makeRow({
|
||||
operation: 'update',
|
||||
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
|
||||
}),
|
||||
];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(typeof capturedIcsString).toBe('string')
|
||||
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true)
|
||||
})
|
||||
expect(typeof capturedIcsString).toBe('string');
|
||||
expect((capturedIcsString as string).startsWith('BEGIN:VCALENDAR')).toBe(true);
|
||||
});
|
||||
|
||||
it('create row with unparseable payload marks the row failed (hard fail, no retry)', async () => {
|
||||
mockPendingRows = [makeRow({ payload: 'NOT_VALID_JSON{{{' })]
|
||||
mockPendingRows = [makeRow({ payload: 'NOT_VALID_JSON{{{' })];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string }
|
||||
expect(setArg?.status).toBe('failed')
|
||||
})
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string };
|
||||
expect(setArg?.status).toBe('failed');
|
||||
});
|
||||
|
||||
// IN-03 (iteration 2): a JSON-parseable but schema-INVALID payload (e.g. missing the
|
||||
// required title) can never produce a valid VEVENT, so the row is hard-failed (no
|
||||
// retry) rather than dispatched with SUMMARY:undefined.
|
||||
it('IN-03: create row with schema-invalid payload (missing title) is hard-failed, never dispatched', async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201))
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(201));
|
||||
// Valid JSON, but title is missing → fails outboxPayloadSchema
|
||||
const badPayload = JSON.stringify({
|
||||
allDay: false,
|
||||
start: '2026-06-10T12:00:00',
|
||||
end: '2026-06-10T13:00:00',
|
||||
})
|
||||
mockPendingRows = [makeRow({ payload: badPayload })]
|
||||
});
|
||||
mockPendingRows = [makeRow({ payload: badPayload })];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// Must NOT have dispatched a CalDAV write with an invalid VEVENT
|
||||
expect(createCalendarEvent).not.toHaveBeenCalled()
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string }
|
||||
expect(setArg?.status).toBe('failed')
|
||||
expect(setArg?.lastError).toMatch(/validation/i)
|
||||
})
|
||||
expect(createCalendarEvent).not.toHaveBeenCalled();
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { status?: string; lastError?: string };
|
||||
expect(setArg?.status).toBe('failed');
|
||||
expect(setArg?.lastError).toMatch(/validation/i);
|
||||
});
|
||||
|
||||
// CR-01 (iteration 2): the edit-as-move create branch must re-apply the RRULE the
|
||||
// route stashed on the payload as `_preservedRrule`, so a moved recurring series keeps
|
||||
// its RRULE instead of collapsing into a single occurrence.
|
||||
it('CR-01: create row re-applies _preservedRrule → emitted ICS contains RRULE:', async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
let capturedIcsString: unknown = null
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
let capturedIcsString: unknown = null;
|
||||
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
|
||||
capturedIcsString = icsString
|
||||
return makeResponse(201)
|
||||
})
|
||||
capturedIcsString = icsString;
|
||||
return makeResponse(201);
|
||||
});
|
||||
// Move payload: no explicit `recurrence`, but the route stashed the source RRULE.
|
||||
const movePayload = JSON.stringify({
|
||||
title: 'Moved weekly standup',
|
||||
@@ -322,25 +332,27 @@ describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
||||
start: '2026-06-10T12:00:00',
|
||||
end: '2026-06-10T13:00:00',
|
||||
_preservedRrule: 'FREQ=WEEKLY;BYDAY=MO',
|
||||
})
|
||||
mockPendingRows = [makeRow({ operation: 'create', payload: movePayload, groupId: 'move-grp-1' })]
|
||||
});
|
||||
mockPendingRows = [
|
||||
makeRow({ operation: 'create', payload: movePayload, groupId: 'move-grp-1' }),
|
||||
];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(typeof capturedIcsString).toBe('string')
|
||||
expect(capturedIcsString as string).toContain('RRULE:')
|
||||
expect(capturedIcsString as string).toContain('FREQ=WEEKLY')
|
||||
})
|
||||
expect(typeof capturedIcsString).toBe('string');
|
||||
expect(capturedIcsString as string).toContain('RRULE:');
|
||||
expect(capturedIcsString as string).toContain('FREQ=WEEKLY');
|
||||
});
|
||||
|
||||
// CR-01 corollary: an explicit `recurrence` on a create still wins over any preserved
|
||||
// RRULE (deliberate user choice); recurrence:'none' must emit no RRULE.
|
||||
it("CR-01: explicit recurrence:'none' wins → emitted ICS has no RRULE even if _preservedRrule present", async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
let capturedIcsString: unknown = null
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
let capturedIcsString: unknown = null;
|
||||
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
|
||||
capturedIcsString = icsString
|
||||
return makeResponse(201)
|
||||
})
|
||||
capturedIcsString = icsString;
|
||||
return makeResponse(201);
|
||||
});
|
||||
const payload = JSON.stringify({
|
||||
title: 'One-off',
|
||||
allDay: false,
|
||||
@@ -348,31 +360,31 @@ describe('runOutboxDrain — ICS building from form JSON (CR-02)', () => {
|
||||
end: '2026-06-10T13:00:00',
|
||||
recurrence: 'none',
|
||||
_preservedRrule: 'FREQ=WEEKLY;BYDAY=MO',
|
||||
})
|
||||
mockPendingRows = [makeRow({ operation: 'create', payload })]
|
||||
});
|
||||
mockPendingRows = [makeRow({ operation: 'create', payload })];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(typeof capturedIcsString).toBe('string')
|
||||
expect(capturedIcsString as string).not.toContain('RRULE:')
|
||||
})
|
||||
})
|
||||
expect(typeof capturedIcsString).toBe('string');
|
||||
expect(capturedIcsString as string).not.toContain('RRULE:');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
vi.resetAllMocks();
|
||||
mockPendingRows = [];
|
||||
wireMockChain();
|
||||
});
|
||||
|
||||
it('processes the create row BEFORE the delete row when both share a groupId', async () => {
|
||||
const { createCalendarEvent, deleteCalendarEvent } = await import('../../src/broker/write.js')
|
||||
const createResponse = makeResponse(201)
|
||||
const deleteResponse = makeResponse(204)
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(createResponse)
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(deleteResponse)
|
||||
const { createCalendarEvent, deleteCalendarEvent } = await import('../../src/broker/write.js');
|
||||
const createResponse = makeResponse(201);
|
||||
const deleteResponse = makeResponse(204);
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(createResponse);
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(deleteResponse);
|
||||
|
||||
const groupId = 'edit-move-group-001'
|
||||
const groupId = 'edit-move-group-001';
|
||||
// delete row listed first (to verify ordering is enforced regardless of order in the array)
|
||||
const deleteRow = makeRow({
|
||||
id: 2,
|
||||
@@ -381,16 +393,16 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
|
||||
etag: '"etag-old"',
|
||||
payload: null,
|
||||
groupId,
|
||||
})
|
||||
});
|
||||
const createRow = makeRow({
|
||||
id: 3,
|
||||
operation: 'create',
|
||||
calendarUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/New/',
|
||||
groupId,
|
||||
})
|
||||
});
|
||||
|
||||
// Both rows in the pending list
|
||||
mockPendingRows = [deleteRow, createRow]
|
||||
mockPendingRows = [deleteRow, createRow];
|
||||
|
||||
// The durable sibling-status check (CR-04) runs for the delete row with groupId.
|
||||
// It queries calendarOutbox for the sibling create's status. By the time the delete
|
||||
@@ -400,32 +412,32 @@ describe('runOutboxDrain — edit-as-move ordering (D-04)', () => {
|
||||
// pending-rows select call.
|
||||
mockWherePending
|
||||
.mockImplementationOnce(() => Promise.resolve([deleteRow, createRow])) // pending-rows select
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])) // sibling-status select
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }])); // sibling-status select
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// CREATE must be called before DELETE
|
||||
const createCall = vi.mocked(createCalendarEvent).mock.invocationCallOrder[0]
|
||||
const deleteCall = vi.mocked(deleteCalendarEvent).mock.invocationCallOrder[0]
|
||||
const createCall = vi.mocked(createCalendarEvent).mock.invocationCallOrder[0];
|
||||
const deleteCall = vi.mocked(deleteCalendarEvent).mock.invocationCallOrder[0];
|
||||
|
||||
// If either was never called, the test will fail naturally.
|
||||
// If create order index > delete order index, create ran AFTER delete — fail.
|
||||
expect(createCall).toBeLessThan(deleteCall)
|
||||
})
|
||||
})
|
||||
expect(createCall).toBeLessThan(deleteCall);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency guard (CR-05)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
vi.resetAllMocks();
|
||||
mockPendingRows = [];
|
||||
wireMockChain();
|
||||
});
|
||||
|
||||
it('CR-04 cross-batch: drain 1 (sibling create still pending) leaves the delete pending and never calls deleteCalendarEvent', async () => {
|
||||
const { deleteCalendarEvent } = await import('../../src/broker/write.js')
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204))
|
||||
const { deleteCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));
|
||||
|
||||
const groupId = 'edit-move-group-001'
|
||||
const groupId = 'edit-move-group-001';
|
||||
const deleteRow = makeRow({
|
||||
id: 2,
|
||||
operation: 'delete',
|
||||
@@ -433,33 +445,33 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency
|
||||
etag: '"etag-old"',
|
||||
payload: null,
|
||||
groupId,
|
||||
})
|
||||
});
|
||||
|
||||
// Drain 1: only the delete row is returned as pending (the create hasn't been fetched yet)
|
||||
// First mockWherePending call → pending-rows select (only the delete row)
|
||||
// Second mockWherePending call → sibling-status select (create is still 'pending')
|
||||
mockWherePending
|
||||
.mockImplementationOnce(() => Promise.resolve([deleteRow]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'pending' }]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'pending' }]));
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// The delete must NOT have been dispatched — sibling create is not yet done
|
||||
expect(deleteCalendarEvent).not.toHaveBeenCalled()
|
||||
expect(deleteCalendarEvent).not.toHaveBeenCalled();
|
||||
|
||||
// The delete row's status must NOT have been updated to done or failed
|
||||
const statusCalls = mockUpdateSet.mock.calls.filter((call) => {
|
||||
const arg = call[0] as { status?: string }
|
||||
return arg?.status === 'done' || arg?.status === 'failed'
|
||||
})
|
||||
expect(statusCalls.length).toBe(0)
|
||||
})
|
||||
const arg = call[0] as { status?: string };
|
||||
return arg?.status === 'done' || arg?.status === 'failed';
|
||||
});
|
||||
expect(statusCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('CR-04 cross-batch: drain 2 (sibling create now done) dispatches the delete exactly once', async () => {
|
||||
const { deleteCalendarEvent } = await import('../../src/broker/write.js')
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204))
|
||||
const { deleteCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));
|
||||
|
||||
const groupId = 'edit-move-group-001'
|
||||
const groupId = 'edit-move-group-001';
|
||||
const deleteRow = makeRow({
|
||||
id: 2,
|
||||
operation: 'delete',
|
||||
@@ -467,23 +479,23 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency
|
||||
etag: '"etag-old"',
|
||||
payload: null,
|
||||
groupId,
|
||||
})
|
||||
});
|
||||
|
||||
// Drain 2: delete row is pending again, sibling create is now 'done'
|
||||
mockWherePending
|
||||
.mockImplementationOnce(() => Promise.resolve([deleteRow]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'done' }]));
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(deleteCalendarEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(deleteCalendarEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('CR-04 paired-create-failed: if sibling create is failed, delete is marked failed and never dispatched (D-04 preserved)', async () => {
|
||||
const { deleteCalendarEvent } = await import('../../src/broker/write.js')
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204))
|
||||
const { deleteCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(deleteCalendarEvent).mockResolvedValue(makeResponse(204));
|
||||
|
||||
const groupId = 'edit-move-group-001'
|
||||
const groupId = 'edit-move-group-001';
|
||||
const deleteRow = makeRow({
|
||||
id: 2,
|
||||
operation: 'delete',
|
||||
@@ -491,162 +503,167 @@ describe('runOutboxDrain — durable create-before-delete (CR-04) + concurrency
|
||||
etag: '"etag-old"',
|
||||
payload: null,
|
||||
groupId,
|
||||
})
|
||||
});
|
||||
|
||||
// Sibling create is 'failed' — the delete must be permanently skipped
|
||||
mockWherePending
|
||||
.mockImplementationOnce(() => Promise.resolve([deleteRow]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'failed' }]))
|
||||
.mockImplementationOnce(() => Promise.resolve([{ status: 'failed' }]));
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// The original event must be preserved — delete must NOT be dispatched
|
||||
expect(deleteCalendarEvent).not.toHaveBeenCalled()
|
||||
expect(deleteCalendarEvent).not.toHaveBeenCalled();
|
||||
|
||||
// The delete row must be marked failed (permanently, not just skipped this cycle)
|
||||
const failedCall = mockUpdateSet.mock.calls.find((call) => {
|
||||
const arg = call[0] as { status?: string; lastError?: string }
|
||||
return arg?.status === 'failed' && typeof arg?.lastError === 'string'
|
||||
})
|
||||
expect(failedCall).toBeDefined()
|
||||
const failArg = failedCall![0] as { lastError: string }
|
||||
expect(failArg.lastError).toMatch(/paired create/)
|
||||
})
|
||||
const arg = call[0] as { status?: string; lastError?: string };
|
||||
return arg?.status === 'failed' && typeof arg?.lastError === 'string';
|
||||
});
|
||||
expect(failedCall).toBeDefined();
|
||||
const failArg = failedCall![0] as { lastError: string };
|
||||
expect(failArg.lastError).toMatch(/paired create/);
|
||||
});
|
||||
|
||||
it('CR-05: two overlapping runOutboxDrain calls invoke createCalendarEvent exactly once', async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
// Simulate a slow create so the second drain starts while first is still running
|
||||
vi.mocked(createCalendarEvent).mockImplementation(
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(makeResponse(201)), 20)),
|
||||
)
|
||||
);
|
||||
|
||||
mockPendingRows = [makeRow({ id: 1 })]
|
||||
mockPendingRows = [makeRow({ id: 1 })];
|
||||
|
||||
// Start both drains concurrently WITHOUT awaiting the first
|
||||
const drain1 = runOutboxDrain()
|
||||
const drain2 = runOutboxDrain()
|
||||
await Promise.all([drain1, drain2])
|
||||
const drain1 = runOutboxDrain();
|
||||
const drain2 = runOutboxDrain();
|
||||
await Promise.all([drain1, drain2]);
|
||||
|
||||
// Only one dispatch must have happened — the second drain must have been a no-op
|
||||
expect(createCalendarEvent).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
expect(createCalendarEvent).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOutboxDrain — fresh etag re-read before PUT (WR-02)', () => {
|
||||
beforeEach(() => {
|
||||
// Use resetAllMocks here (not clearAllMocks) so that unconsumed mockImplementationOnce
|
||||
// queues from prior tests do not bleed into subsequent tests via the shared mockWherePending.
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
vi.resetAllMocks();
|
||||
mockPendingRows = [];
|
||||
wireMockChain();
|
||||
});
|
||||
|
||||
it('WR-02 fresh etag: update PUT uses freshest calendarEvents.etag, not stale enqueue-time etag', async () => {
|
||||
const { updateCalendarEvent } = await import('../../src/broker/write.js')
|
||||
let capturedEtag: string | null = null
|
||||
const { updateCalendarEvent } = await import('../../src/broker/write.js');
|
||||
let capturedEtag: string | null = null;
|
||||
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => {
|
||||
capturedEtag = etag
|
||||
return makeResponse(204)
|
||||
})
|
||||
capturedEtag = etag;
|
||||
return makeResponse(204);
|
||||
});
|
||||
|
||||
const updateRow = makeRow({
|
||||
operation: 'update',
|
||||
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
|
||||
etag: 'old-etag', // stale enqueue-time etag
|
||||
})
|
||||
mockPendingRows = [updateRow]
|
||||
});
|
||||
mockPendingRows = [updateRow];
|
||||
|
||||
// Mock the calendarEvents etag lookup to return a fresher etag.
|
||||
// In RED (no fresh-etag code yet), mockWhereCalEvents is never called, so
|
||||
// the PUT uses row.etag = 'old-etag'. The assertion expects 'new-etag' → fails RED.
|
||||
mockWhereCalEvents.mockResolvedValue([{ etag: 'new-etag' }])
|
||||
mockWhereCalEvents.mockResolvedValue([{ etag: 'new-etag' }]);
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// The PUT must use the freshest etag from calendarEvents, not the stale row.etag
|
||||
expect(capturedEtag).toBe('new-etag')
|
||||
expect(capturedEtag).not.toBe('old-etag')
|
||||
})
|
||||
expect(capturedEtag).toBe('new-etag');
|
||||
expect(capturedEtag).not.toBe('old-etag');
|
||||
});
|
||||
|
||||
it('WR-02 etag fallback: update PUT falls back to row.etag when calendarEvents has no matching row', async () => {
|
||||
const { updateCalendarEvent } = await import('../../src/broker/write.js')
|
||||
let capturedEtag: string | null = null
|
||||
const { updateCalendarEvent } = await import('../../src/broker/write.js');
|
||||
let capturedEtag: string | null = null;
|
||||
vi.mocked(updateCalendarEvent).mockImplementation(async (_client, _url, _ics, etag) => {
|
||||
capturedEtag = etag
|
||||
return makeResponse(204)
|
||||
})
|
||||
capturedEtag = etag;
|
||||
return makeResponse(204);
|
||||
});
|
||||
|
||||
const updateRow = makeRow({
|
||||
operation: 'update',
|
||||
calendarObjectUrl: 'https://caldav.fastmail.com/dav/calendars/user/test/uid.ics',
|
||||
etag: 'fallback-etag',
|
||||
})
|
||||
mockPendingRows = [updateRow]
|
||||
});
|
||||
mockPendingRows = [updateRow];
|
||||
|
||||
// mockWhereCalEvents is already configured to return [] by default in wireMockChain.
|
||||
// No row for the uid → worker falls back to row.etag.
|
||||
// In RED, mockWhereCalEvents is never called so the test passes (row.etag used directly).
|
||||
// In GREEN, mockWhereCalEvents returns [] so the fallback is exercised.
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// When calendarEvents has no row for the uid, fall back to row.etag
|
||||
expect(capturedEtag).toBe('fallback-etag')
|
||||
})
|
||||
})
|
||||
expect(capturedEtag).toBe('fallback-etag');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff index fix (WR-01)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
vi.resetAllMocks();
|
||||
mockPendingRows = [];
|
||||
wireMockChain();
|
||||
});
|
||||
|
||||
it('CR-03: credential-load failure leaves row pending and never calls createFastmailClient with empty credentials', async () => {
|
||||
// Make decryptPassword throw so loadClientForUser throws
|
||||
mockDecryptPassword.mockImplementation(() => { throw new Error('bad credentials') })
|
||||
mockDecryptPassword.mockImplementation(() => {
|
||||
throw new Error('bad credentials');
|
||||
});
|
||||
|
||||
const { createFastmailClient } = await import('../../src/broker/client.js')
|
||||
mockPendingRows = [makeRow()]
|
||||
const { createFastmailClient } = await import('../../src/broker/client.js');
|
||||
mockPendingRows = [makeRow()];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
// The row must NOT be updated to done/failed/dead — it stays pending (outer catch handles it)
|
||||
const updateCalls = mockUpdateSet.mock.calls
|
||||
const updateCalls = mockUpdateSet.mock.calls;
|
||||
const anyStatusChange = updateCalls.some((call) => {
|
||||
const arg = call[0] as { status?: string }
|
||||
return arg?.status !== undefined
|
||||
})
|
||||
expect(anyStatusChange).toBe(false)
|
||||
const arg = call[0] as { status?: string };
|
||||
return arg?.status !== undefined;
|
||||
});
|
||||
expect(anyStatusChange).toBe(false);
|
||||
|
||||
// createFastmailClient must NEVER be called with empty-string credentials
|
||||
const emptyCalls = vi.mocked(createFastmailClient).mock.calls.filter(
|
||||
([email, password]) => email === '' || password === ''
|
||||
)
|
||||
expect(emptyCalls.length).toBe(0)
|
||||
})
|
||||
const emptyCalls = vi
|
||||
.mocked(createFastmailClient)
|
||||
.mock.calls.filter(([email, password]) => email === '' || password === '');
|
||||
expect(emptyCalls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('WR-01: first transient failure (attemptCount=0) sets backoff to ~15s (BACKOFF_SECONDS[0])', async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500))
|
||||
const row = makeRow({ attemptCount: 0 })
|
||||
mockPendingRows = [row]
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
vi.mocked(createCalendarEvent).mockResolvedValue(makeResponse(500));
|
||||
const row = makeRow({ attemptCount: 0 });
|
||||
mockPendingRows = [row];
|
||||
|
||||
const beforeDrain = Date.now()
|
||||
await runOutboxDrain()
|
||||
const afterDrain = Date.now()
|
||||
const beforeDrain = Date.now();
|
||||
await runOutboxDrain();
|
||||
const afterDrain = Date.now();
|
||||
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as { nextAttemptAt?: Date; attemptCount?: number }
|
||||
expect(setArg?.attemptCount).toBe(1)
|
||||
const setArg = mockUpdateSet.mock.calls[0]?.[0] as {
|
||||
nextAttemptAt?: Date;
|
||||
attemptCount?: number;
|
||||
};
|
||||
expect(setArg?.attemptCount).toBe(1);
|
||||
|
||||
// WR-01: nextAttemptAt must be ~15s in the future (BACKOFF_SECONDS[0] = 15)
|
||||
// Allow ±2s for execution overhead
|
||||
const expectedMinMs = beforeDrain + 14_000
|
||||
const expectedMaxMs = afterDrain + 16_000
|
||||
expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThanOrEqual(expectedMinMs)
|
||||
expect(setArg?.nextAttemptAt?.getTime()).toBeLessThanOrEqual(expectedMaxMs)
|
||||
})
|
||||
})
|
||||
const expectedMinMs = beforeDrain + 14_000;
|
||||
const expectedMaxMs = afterDrain + 16_000;
|
||||
expect(setArg?.nextAttemptAt?.getTime()).toBeGreaterThanOrEqual(expectedMinMs);
|
||||
expect(setArg?.nextAttemptAt?.getTime()).toBeLessThanOrEqual(expectedMaxMs);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── D-06: assembleRruleString unit tests ─────────────────────────────────────
|
||||
// These tests import the NOT-YET-EXPORTED `assembleRruleString` helper.
|
||||
@@ -654,29 +671,33 @@ describe('runOutboxDrain — fail closed on bad credentials (CR-03) + backoff in
|
||||
|
||||
describe('assembleRruleString (D-06)', () => {
|
||||
it('returns base preset unchanged when no bound given', () => {
|
||||
expect(assembleRruleString('FREQ=DAILY')).toBe('FREQ=DAILY')
|
||||
})
|
||||
expect(assembleRruleString('FREQ=DAILY')).toBe('FREQ=DAILY');
|
||||
});
|
||||
|
||||
it('appends COUNT when count is given (count wins over until)', () => {
|
||||
expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5')
|
||||
})
|
||||
expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5');
|
||||
});
|
||||
|
||||
it('COUNT wins when both until and count are provided (mutual exclusion, RFC 5545 §3.3.10)', () => {
|
||||
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', 5, false)).toBe('FREQ=WEEKLY;COUNT=5')
|
||||
})
|
||||
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', 5, false)).toBe('FREQ=WEEKLY;COUNT=5');
|
||||
});
|
||||
|
||||
it('appends UNTIL as DATE form (YYYYMMDD) for all-day events', () => {
|
||||
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, true)).toBe('FREQ=WEEKLY;UNTIL=20260630')
|
||||
})
|
||||
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, true)).toBe(
|
||||
'FREQ=WEEKLY;UNTIL=20260630',
|
||||
);
|
||||
});
|
||||
|
||||
it('appends UNTIL as DATETIME UTC form (YYYYMMDDTHHMMSSZ) for timed events', () => {
|
||||
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, false)).toBe('FREQ=WEEKLY;UNTIL=20260630T235959Z')
|
||||
})
|
||||
expect(assembleRruleString('FREQ=WEEKLY', '2026-06-30', undefined, false)).toBe(
|
||||
'FREQ=WEEKLY;UNTIL=20260630T235959Z',
|
||||
);
|
||||
});
|
||||
|
||||
it('COUNT=5 appended to FREQ=DAILY (matches plan behavior assertion)', () => {
|
||||
expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5')
|
||||
})
|
||||
})
|
||||
expect(assembleRruleString('FREQ=DAILY', undefined, 5, false)).toBe('FREQ=DAILY;COUNT=5');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── D-07: FREQ-persistence regression lock ────────────────────────────────────
|
||||
// RED: will fail because the outbox worker does not yet wire recurrenceUntil/recurrenceCount
|
||||
@@ -684,32 +705,32 @@ describe('assembleRruleString (D-06)', () => {
|
||||
|
||||
describe('FREQ persistence (D-07 regression)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
mockPendingRows = []
|
||||
wireMockChain()
|
||||
})
|
||||
vi.resetAllMocks();
|
||||
mockPendingRows = [];
|
||||
wireMockChain();
|
||||
});
|
||||
|
||||
it('D-07: daily-recurrence payload assembles to FREQ=DAILY (not weekly or none) in emitted ICS', async () => {
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js')
|
||||
let capturedIcsString: unknown = null
|
||||
const { createCalendarEvent } = await import('../../src/broker/write.js');
|
||||
let capturedIcsString: unknown = null;
|
||||
vi.mocked(createCalendarEvent).mockImplementation(async (_client, _cal, _uid, icsString) => {
|
||||
capturedIcsString = icsString
|
||||
return makeResponse(201)
|
||||
})
|
||||
capturedIcsString = icsString;
|
||||
return makeResponse(201);
|
||||
});
|
||||
const dailyPayload = JSON.stringify({
|
||||
title: 'Daily standup',
|
||||
allDay: false,
|
||||
start: '2026-06-10T09:00:00',
|
||||
end: '2026-06-10T09:30:00',
|
||||
recurrence: 'daily',
|
||||
})
|
||||
mockPendingRows = [makeRow({ payload: dailyPayload })]
|
||||
});
|
||||
mockPendingRows = [makeRow({ payload: dailyPayload })];
|
||||
|
||||
await runOutboxDrain()
|
||||
await runOutboxDrain();
|
||||
|
||||
expect(typeof capturedIcsString).toBe('string')
|
||||
expect(typeof capturedIcsString).toBe('string');
|
||||
// D-07: FREQ must be DAILY — not WEEKLY or absent
|
||||
expect(capturedIcsString as string).toContain('RRULE:FREQ=DAILY')
|
||||
expect(capturedIcsString as string).not.toContain('FREQ=WEEKLY')
|
||||
})
|
||||
})
|
||||
expect(capturedIcsString as string).toContain('RRULE:FREQ=DAILY');
|
||||
expect(capturedIcsString as string).not.toContain('FREQ=WEEKLY');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,19 +19,19 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/broker/reminderScheduler.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// Mock the DB so we can control what events are returned
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock pushDispatcher so no real push occurs
|
||||
vi.mock('../../src/lib/pushDispatcher.js', () => ({
|
||||
dispatchPush: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
}));
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -44,18 +44,18 @@ function makeSelectMock(rows: unknown[]) {
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
} as never
|
||||
} as never;
|
||||
}
|
||||
|
||||
function makeEventRow(overrides: {
|
||||
uid?: string
|
||||
title?: string
|
||||
dtstartUtc: Date
|
||||
subId?: number | null
|
||||
subUserId?: number | null
|
||||
subEndpoint?: string
|
||||
subP256dh?: string
|
||||
subAuth?: string
|
||||
uid?: string;
|
||||
title?: string;
|
||||
dtstartUtc: Date;
|
||||
subId?: number | null;
|
||||
subUserId?: number | null;
|
||||
subEndpoint?: string;
|
||||
subP256dh?: string;
|
||||
subAuth?: string;
|
||||
}) {
|
||||
return {
|
||||
uid: overrides.uid ?? 'test-uid-1',
|
||||
@@ -68,162 +68,183 @@ function makeEventRow(overrides: {
|
||||
subEndpoint: overrides.subEndpoint ?? 'https://push.example.com/1',
|
||||
subP256dh: overrides.subP256dh ?? 'p256dh-key',
|
||||
subAuth: overrides.subAuth ?? 'auth-secret',
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ── Filtering tests (D-05 / D-07) ────────────────────────────────────────────
|
||||
|
||||
describe('reminderScheduler — shared+timed event filtering (D-05/D-07)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not dispatch reminders for all-day events (D-07)', async () => {
|
||||
const now = new Date('2026-06-15T10:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const now = new Date('2026-06-15T10:00:00Z');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||
|
||||
// allDay=true events are excluded by the SQL WHERE; simulate by returning empty rows
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([]))
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
|
||||
|
||||
await runReminderCheck()
|
||||
await runReminderCheck();
|
||||
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch reminders for non-shared (personal) calendar events (D-05)', async () => {
|
||||
const now = new Date('2026-06-15T10:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const now = new Date('2026-06-15T10:00:00Z');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||
|
||||
// isShared=false events are excluded by the SQL WHERE; simulate by returning empty rows
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([]))
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
|
||||
|
||||
await runReminderCheck()
|
||||
await runReminderCheck();
|
||||
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not dispatch for an event whose start has already passed (dtstart <= now)', async () => {
|
||||
const now = new Date('2026-06-15T10:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const now = new Date('2026-06-15T10:00:00Z');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||
|
||||
// gt(dtstartUtc, now) excludes already-started events; simulate by returning empty rows
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([]))
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
|
||||
|
||||
await runReminderCheck(now)
|
||||
await runReminderCheck(now);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── D-16: empty subscriptions — zero sends / no crash ────────────────────────
|
||||
|
||||
describe('reminderScheduler — D-16: empty push_subscriptions', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('produces zero sends and does not crash when push_subscriptions is empty (D-16)', async () => {
|
||||
const now = new Date('2026-06-15T10:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const now = new Date('2026-06-15T10:00:00Z');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||
|
||||
// Cross-join with empty push_subscriptions returns no rows
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([]))
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
|
||||
|
||||
await expect(runReminderCheck(now)).resolves.toBeUndefined()
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
await expect(runReminderCheck(now)).resolves.toBeUndefined();
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Catch-up / missed-tick recovery ──────────────────────────────────────────
|
||||
|
||||
describe('reminderScheduler — catch-up window and missed-tick recovery', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('SINGLE-FIRE: dispatches exactly once across three consecutive ticks while event is in window', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||
|
||||
const t0 = new Date('2026-06-15T10:00:00Z')
|
||||
const t0 = new Date('2026-06-15T10:00:00Z');
|
||||
// Event is 15 min out from t0; still in (now, now+16min] at t0+1min (14 min out) and t0+2min (13 min out)
|
||||
const eventDtstart = new Date('2026-06-15T10:15:00Z')
|
||||
const eventDtstart = new Date('2026-06-15T10:15:00Z');
|
||||
|
||||
const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/1', p256dh: 'k', auth: 'a' }
|
||||
const sub = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
endpoint: 'https://push.example.com/1',
|
||||
p256dh: 'k',
|
||||
auth: 'a',
|
||||
};
|
||||
|
||||
function rowForNow() {
|
||||
return makeEventRow({ uid: 'single-fire-uid', dtstartUtc: eventDtstart, ...sub, subId: sub.id, subUserId: sub.userId, subEndpoint: sub.endpoint, subP256dh: sub.p256dh, subAuth: sub.auth })
|
||||
return makeEventRow({
|
||||
uid: 'single-fire-uid',
|
||||
dtstartUtc: eventDtstart,
|
||||
...sub,
|
||||
subId: sub.id,
|
||||
subUserId: sub.userId,
|
||||
subEndpoint: sub.endpoint,
|
||||
subP256dh: sub.p256dh,
|
||||
subAuth: sub.auth,
|
||||
});
|
||||
}
|
||||
|
||||
// Tick at t0 (event 15 min out)
|
||||
vi.setSystemTime(t0)
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]))
|
||||
await runReminderCheck(t0)
|
||||
vi.setSystemTime(t0);
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
|
||||
await runReminderCheck(t0);
|
||||
|
||||
// Tick at t0+1min (event 14 min out — still in window, same uid)
|
||||
const t1 = new Date(t0.getTime() + 60 * 1000)
|
||||
vi.setSystemTime(t1)
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]))
|
||||
await runReminderCheck(t1)
|
||||
const t1 = new Date(t0.getTime() + 60 * 1000);
|
||||
vi.setSystemTime(t1);
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
|
||||
await runReminderCheck(t1);
|
||||
|
||||
// Tick at t0+2min (event 13 min out — still in window, same uid)
|
||||
const t2 = new Date(t0.getTime() + 2 * 60 * 1000)
|
||||
vi.setSystemTime(t2)
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]))
|
||||
await runReminderCheck(t2)
|
||||
const t2 = new Date(t0.getTime() + 2 * 60 * 1000);
|
||||
vi.setSystemTime(t2);
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([rowForNow()]));
|
||||
await runReminderCheck(t2);
|
||||
|
||||
// Exactly one dispatch total: uid dedup prevents re-fire on ticks 2 and 3
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1)
|
||||
})
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('MISSED-TICK-RECOVERY: fires when scan runs 8 min before event after ideal tick was skipped', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||
|
||||
// Event at 10:15:00Z. The ideal 15-min scan (10:00:00Z) was missed.
|
||||
// Call at 10:07:00Z — event is 8 min out, still > now and <= now+16min.
|
||||
const eventDtstart = new Date('2026-06-15T10:15:00Z')
|
||||
const recoveryNow = new Date('2026-06-15T10:07:00Z')
|
||||
const eventDtstart = new Date('2026-06-15T10:15:00Z');
|
||||
const recoveryNow = new Date('2026-06-15T10:07:00Z');
|
||||
|
||||
vi.setSystemTime(recoveryNow)
|
||||
vi.setSystemTime(recoveryNow);
|
||||
|
||||
const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/missed', p256dh: 'k', auth: 'a' }
|
||||
const sub = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
endpoint: 'https://push.example.com/missed',
|
||||
p256dh: 'k',
|
||||
auth: 'a',
|
||||
};
|
||||
const row = makeEventRow({
|
||||
uid: 'missed-tick-uid',
|
||||
dtstartUtc: eventDtstart,
|
||||
@@ -232,63 +253,85 @@ describe('reminderScheduler — catch-up window and missed-tick recovery', () =>
|
||||
subEndpoint: sub.endpoint,
|
||||
subP256dh: sub.p256dh,
|
||||
subAuth: sub.auth,
|
||||
})
|
||||
});
|
||||
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([row]))
|
||||
await runReminderCheck(recoveryNow)
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([row]));
|
||||
await runReminderCheck(recoveryNow);
|
||||
|
||||
// Reminder must fire even though the ideal-mark tick was skipped
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('dispatches to all subscribers for a single event (fan-out)', async () => {
|
||||
const now = new Date('2026-06-15T10:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const now = new Date('2026-06-15T10:00:00Z');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||
|
||||
const dtstartUtc = new Date('2026-06-15T10:15:00Z')
|
||||
const dtstartUtc = new Date('2026-06-15T10:15:00Z');
|
||||
|
||||
const rows = [
|
||||
makeEventRow({ uid: 'fanout-uid', dtstartUtc, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/1', subP256dh: 'k1', subAuth: 'a1' }),
|
||||
makeEventRow({ uid: 'fanout-uid', dtstartUtc, subId: 2, subUserId: 2, subEndpoint: 'https://push.example.com/2', subP256dh: 'k2', subAuth: 'a2' }),
|
||||
]
|
||||
makeEventRow({
|
||||
uid: 'fanout-uid',
|
||||
dtstartUtc,
|
||||
subId: 1,
|
||||
subUserId: 1,
|
||||
subEndpoint: 'https://push.example.com/1',
|
||||
subP256dh: 'k1',
|
||||
subAuth: 'a1',
|
||||
}),
|
||||
makeEventRow({
|
||||
uid: 'fanout-uid',
|
||||
dtstartUtc,
|
||||
subId: 2,
|
||||
subUserId: 2,
|
||||
subEndpoint: 'https://push.example.com/2',
|
||||
subP256dh: 'k2',
|
||||
subAuth: 'a2',
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock(rows))
|
||||
await runReminderCheck(now)
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock(rows));
|
||||
await runReminderCheck(now);
|
||||
|
||||
// One dispatch per subscriber
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2)
|
||||
})
|
||||
})
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
// ── WR-01: mark-sent after dispatch ──────────────────────────────────────────
|
||||
|
||||
describe('reminderScheduler — WR-01: mark-sent after dispatch', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('does not re-dispatch the same uid after a successful dispatch in the same module instance', async () => {
|
||||
// WR-01: sentReminders.set(uid) is called AFTER the fan-out loop completes.
|
||||
// After a successful dispatch the uid is recorded; a second run with the same
|
||||
// module instance must not re-fire for the same uid.
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||
|
||||
const now = new Date('2026-06-15T10:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const now = new Date('2026-06-15T10:00:00Z');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/1', p256dh: 'k', auth: 'a' }
|
||||
const sub = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
endpoint: 'https://push.example.com/1',
|
||||
p256dh: 'k',
|
||||
auth: 'a',
|
||||
};
|
||||
const eventRow = makeEventRow({
|
||||
uid: 'wr01-dedup-uid',
|
||||
title: 'WR-01 dedup event',
|
||||
@@ -298,43 +341,49 @@ describe('reminderScheduler — WR-01: mark-sent after dispatch', () => {
|
||||
subEndpoint: sub.endpoint,
|
||||
subP256dh: sub.p256dh,
|
||||
subAuth: sub.auth,
|
||||
})
|
||||
});
|
||||
|
||||
vi.mocked(dispatchPush).mockResolvedValue(undefined)
|
||||
vi.mocked(dispatchPush).mockResolvedValue(undefined);
|
||||
|
||||
// First run — dispatch succeeds; uid is recorded after the loop
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]))
|
||||
await runReminderCheck(now)
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1) // dispatched once
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
|
||||
await runReminderCheck(now);
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // dispatched once
|
||||
|
||||
// Second run with same now — uid is in sentReminders; should NOT re-dispatch
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]))
|
||||
await runReminderCheck(now)
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1) // still 1 — deduped
|
||||
})
|
||||
})
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
|
||||
await runReminderCheck(now);
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1 — deduped
|
||||
});
|
||||
});
|
||||
|
||||
// ── CR-01: started-event pruning ─────────────────────────────────────────────
|
||||
|
||||
describe('reminderScheduler — CR-01: sentReminders Map pruning', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('prunes started-event entries so a different future event with the same uid can fire again', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||
|
||||
const sub = { id: 1, userId: 1, endpoint: 'https://push.example.com/1', p256dh: 'k', auth: 'a' }
|
||||
const eventDtstart = new Date('2026-06-15T10:15:00Z')
|
||||
const uid = 'prune-test-uid'
|
||||
const sub = {
|
||||
id: 1,
|
||||
userId: 1,
|
||||
endpoint: 'https://push.example.com/1',
|
||||
p256dh: 'k',
|
||||
auth: 'a',
|
||||
};
|
||||
const eventDtstart = new Date('2026-06-15T10:15:00Z');
|
||||
const uid = 'prune-test-uid';
|
||||
|
||||
const eventRow = makeEventRow({
|
||||
uid,
|
||||
@@ -345,65 +394,81 @@ describe('reminderScheduler — CR-01: sentReminders Map pruning', () => {
|
||||
subEndpoint: sub.endpoint,
|
||||
subP256dh: sub.p256dh,
|
||||
subAuth: sub.auth,
|
||||
})
|
||||
});
|
||||
|
||||
// Tick at t0 (now=10:00): event 15 min out — fires
|
||||
const t0 = new Date('2026-06-15T10:00:00Z')
|
||||
vi.setSystemTime(t0)
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]))
|
||||
await runReminderCheck(t0)
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1) // fired
|
||||
const t0 = new Date('2026-06-15T10:00:00Z');
|
||||
vi.setSystemTime(t0);
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
|
||||
await runReminderCheck(t0);
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // fired
|
||||
|
||||
// Same t0 — uid still in Map — must NOT re-fire
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]))
|
||||
await runReminderCheck(t0)
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1) // still 1
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([eventRow]));
|
||||
await runReminderCheck(t0);
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1); // still 1
|
||||
|
||||
// Advance past dtstart (now=10:20): event has started; CR-01 prunes the uid entry.
|
||||
// The SQL WHERE gt(dtstartUtc, now) would return no rows, so simulate empty.
|
||||
const tPast = new Date('2026-06-15T10:20:00Z')
|
||||
vi.setSystemTime(tPast)
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([]))
|
||||
await runReminderCheck(tPast)
|
||||
const tPast = new Date('2026-06-15T10:20:00Z');
|
||||
vi.setSystemTime(tPast);
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock([]));
|
||||
await runReminderCheck(tPast);
|
||||
// Pruning fires; dispatch count stays at 1
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1)
|
||||
})
|
||||
})
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── T-05-19: per-subscription error isolation ────────────────────────────────
|
||||
|
||||
describe('reminderScheduler — T-05-19: per-subscription error isolation', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('continues dispatching to remaining subscribers when one subscription throws', async () => {
|
||||
const now = new Date('2026-06-15T10:00:00Z')
|
||||
vi.setSystemTime(now)
|
||||
const now = new Date('2026-06-15T10:00:00Z');
|
||||
vi.setSystemTime(now);
|
||||
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { runReminderCheck } = await import('../../src/broker/reminderScheduler.js');
|
||||
|
||||
const dtstartUtc = new Date('2026-06-15T10:15:00Z')
|
||||
const dtstartUtc = new Date('2026-06-15T10:15:00Z');
|
||||
const rows = [
|
||||
makeEventRow({ uid: 'iso-uid', dtstartUtc, subId: 1, subUserId: 1, subEndpoint: 'https://push.example.com/1', subP256dh: 'k1', subAuth: 'a1' }),
|
||||
makeEventRow({ uid: 'iso-uid', dtstartUtc, subId: 2, subUserId: 2, subEndpoint: 'https://push.example.com/2', subP256dh: 'k2', subAuth: 'a2' }),
|
||||
]
|
||||
makeEventRow({
|
||||
uid: 'iso-uid',
|
||||
dtstartUtc,
|
||||
subId: 1,
|
||||
subUserId: 1,
|
||||
subEndpoint: 'https://push.example.com/1',
|
||||
subP256dh: 'k1',
|
||||
subAuth: 'a1',
|
||||
}),
|
||||
makeEventRow({
|
||||
uid: 'iso-uid',
|
||||
dtstartUtc,
|
||||
subId: 2,
|
||||
subUserId: 2,
|
||||
subEndpoint: 'https://push.example.com/2',
|
||||
subP256dh: 'k2',
|
||||
subAuth: 'a2',
|
||||
}),
|
||||
];
|
||||
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock(rows))
|
||||
vi.mocked(db.select).mockReturnValue(makeSelectMock(rows));
|
||||
// First subscriber throws; second should still be attempted
|
||||
vi.mocked(dispatchPush)
|
||||
.mockRejectedValueOnce(new Error('network error'))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
|
||||
await expect(runReminderCheck(now)).resolves.toBeUndefined()
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2) // both attempted
|
||||
})
|
||||
})
|
||||
await expect(runReminderCheck(now)).resolves.toBeUndefined();
|
||||
expect(vi.mocked(dispatchPush).mock.calls.length).toBe(2); // both attempted
|
||||
});
|
||||
});
|
||||
|
||||
+197
-180
@@ -10,25 +10,25 @@
|
||||
* - Calendar ctag/syncToken updated after sync
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import {
|
||||
SAMPLE_VEVENT_TIMED,
|
||||
SAMPLE_VEVENT_ALLDAY,
|
||||
SAMPLE_VEVENT_RECURRING_TIMED,
|
||||
SAMPLE_VEVENT_RECURRING_ALLDAY,
|
||||
} from '../helpers/db.js'
|
||||
} from '../helpers/db.js';
|
||||
|
||||
// Track calls for assertions
|
||||
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ insertId: 1 }])
|
||||
const mockValues = vi.fn().mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate })
|
||||
const mockInsert = vi.fn().mockReturnValue({ values: mockValues })
|
||||
const mockLimit = vi.fn().mockResolvedValue([{ id: 42 }])
|
||||
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit })
|
||||
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere })
|
||||
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom })
|
||||
const mockOnDuplicateKeyUpdate = vi.fn().mockResolvedValue([{ insertId: 1 }]);
|
||||
const mockValues = vi.fn().mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate });
|
||||
const mockInsert = vi.fn().mockReturnValue({ values: mockValues });
|
||||
const mockLimit = vi.fn().mockResolvedValue([{ id: 42 }]);
|
||||
const mockWhere = vi.fn().mockReturnValue({ limit: mockLimit });
|
||||
const mockFrom = vi.fn().mockReturnValue({ where: mockWhere });
|
||||
const mockSelect = vi.fn().mockReturnValue({ from: mockFrom });
|
||||
// Prune chain: db.delete(calendarEvents).where(...)
|
||||
const mockDeleteWhere = vi.fn().mockResolvedValue([])
|
||||
const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere })
|
||||
const mockDeleteWhere = vi.fn().mockResolvedValue([]);
|
||||
const mockDelete = vi.fn().mockReturnValue({ where: mockDeleteWhere });
|
||||
|
||||
// Mock the db singleton at module level (Vitest hoisting)
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
@@ -37,334 +37,350 @@ vi.mock('../../src/db/client.js', () => ({
|
||||
select: mockSelect,
|
||||
delete: mockDelete,
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('syncCalendar', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.clearAllMocks();
|
||||
// Reset mock implementations after clearAllMocks
|
||||
mockOnDuplicateKeyUpdate.mockResolvedValue([{ insertId: 1 }])
|
||||
mockValues.mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate })
|
||||
mockInsert.mockReturnValue({ values: mockValues })
|
||||
mockLimit.mockResolvedValue([{ id: 42 }])
|
||||
mockWhere.mockReturnValue({ limit: mockLimit })
|
||||
mockFrom.mockReturnValue({ where: mockWhere })
|
||||
mockSelect.mockReturnValue({ from: mockFrom })
|
||||
mockDeleteWhere.mockResolvedValue([])
|
||||
mockDelete.mockReturnValue({ where: mockDeleteWhere })
|
||||
})
|
||||
mockOnDuplicateKeyUpdate.mockResolvedValue([{ insertId: 1 }]);
|
||||
mockValues.mockReturnValue({ onDuplicateKeyUpdate: mockOnDuplicateKeyUpdate });
|
||||
mockInsert.mockReturnValue({ values: mockValues });
|
||||
mockLimit.mockResolvedValue([{ id: 42 }]);
|
||||
mockWhere.mockReturnValue({ limit: mockLimit });
|
||||
mockFrom.mockReturnValue({ where: mockWhere });
|
||||
mockSelect.mockReturnValue({ from: mockFrom });
|
||||
mockDeleteWhere.mockResolvedValue([]);
|
||||
mockDelete.mockReturnValue({ where: mockDeleteWhere });
|
||||
});
|
||||
|
||||
it('stores all-day events with dtstart_date (DATE) and dtstart_utc=NULL', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_ALLDAY, etag: '"etag-allday"', url: '/cal/allday.ics' },
|
||||
]),
|
||||
}
|
||||
fetchCalendarObjects: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_ALLDAY, etag: '"etag-allday"', url: '/cal/allday.ics' },
|
||||
]),
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test Calendar',
|
||||
ctag: 'ctag-v1',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
|
||||
// insert called twice: calendars + calendarEvents
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2)
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2);
|
||||
// Event insert: second call's values arg
|
||||
const eventValuesArg = mockValues.mock.calls[1][0]
|
||||
expect(eventValuesArg.allDay).toBe(true)
|
||||
expect(eventValuesArg.dtstartDate).toBeTruthy()
|
||||
expect(eventValuesArg.dtstartUtc).toBeNull()
|
||||
})
|
||||
const eventValuesArg = mockValues.mock.calls[1][0];
|
||||
expect(eventValuesArg.allDay).toBe(true);
|
||||
expect(eventValuesArg.dtstartDate).toBeTruthy();
|
||||
expect(eventValuesArg.dtstartUtc).toBeNull();
|
||||
});
|
||||
|
||||
it('stores timed events with dtstart_utc (TIMESTAMP) and dtstart_date=NULL', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.ics' },
|
||||
]),
|
||||
}
|
||||
fetchCalendarObjects: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.ics' },
|
||||
]),
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test Calendar',
|
||||
ctag: 'ctag-v1',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2)
|
||||
const eventValuesArg = mockValues.mock.calls[1][0]
|
||||
expect(eventValuesArg.allDay).toBe(false)
|
||||
expect(eventValuesArg.dtstartUtc).toBeInstanceOf(Date)
|
||||
expect(eventValuesArg.dtstartDate).toBeNull()
|
||||
})
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2);
|
||||
const eventValuesArg = mockValues.mock.calls[1][0];
|
||||
expect(eventValuesArg.allDay).toBe(false);
|
||||
expect(eventValuesArg.dtstartUtc).toBeInstanceOf(Date);
|
||||
expect(eventValuesArg.dtstartDate).toBeNull();
|
||||
});
|
||||
|
||||
it('sets allDay=true for all-day events, allDay=false for timed', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_ALLDAY, etag: '"allday"', url: '/allday.ics' },
|
||||
]),
|
||||
}
|
||||
fetchCalendarObjects: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ data: SAMPLE_VEVENT_ALLDAY, etag: '"allday"', url: '/allday.ics' }]),
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Test/',
|
||||
displayName: 'Test',
|
||||
ctag: null,
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
const eventArg = mockValues.mock.calls[1][0]
|
||||
expect(eventArg.allDay).toBe(true)
|
||||
})
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
const eventArg = mockValues.mock.calls[1][0];
|
||||
expect(eventArg.allDay).toBe(true);
|
||||
});
|
||||
|
||||
it('upserts on duplicate UID within the same calendar (onDuplicateKeyUpdate called for events)', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag1"', url: '/timed.ics' },
|
||||
]),
|
||||
}
|
||||
fetchCalendarObjects: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ data: SAMPLE_VEVENT_TIMED, etag: '"etag1"', url: '/timed.ics' }]),
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test',
|
||||
ctag: 'v2',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
|
||||
// onDuplicateKeyUpdate must be called for both the calendar upsert and the event upsert
|
||||
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stores the raw VEVENT blob in rawVevent column', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag"', url: '/timed.ics' },
|
||||
]),
|
||||
}
|
||||
fetchCalendarObjects: vi
|
||||
.fn()
|
||||
.mockResolvedValue([{ data: SAMPLE_VEVENT_TIMED, etag: '"etag"', url: '/timed.ics' }]),
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test',
|
||||
ctag: 'v1',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
const eventArg = mockValues.mock.calls[1][0]
|
||||
expect(eventArg.rawVevent).toBe(SAMPLE_VEVENT_TIMED)
|
||||
})
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
const eventArg = mockValues.mock.calls[1][0];
|
||||
expect(eventArg.rawVevent).toBe(SAMPLE_VEVENT_TIMED);
|
||||
});
|
||||
|
||||
it('sets hasRrule=true for a VEVENT with RRULE (timed recurring)', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-rrule"', url: '/rrule.ics' },
|
||||
]),
|
||||
}
|
||||
fetchCalendarObjects: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-rrule"', url: '/rrule.ics' },
|
||||
]),
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test Calendar',
|
||||
ctag: 'ctag-v1',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2)
|
||||
const eventValuesArg = mockValues.mock.calls[1][0]
|
||||
expect(eventValuesArg.hasRrule).toBe(true)
|
||||
})
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2);
|
||||
const eventValuesArg = mockValues.mock.calls[1][0];
|
||||
expect(eventValuesArg.hasRrule).toBe(true);
|
||||
});
|
||||
|
||||
it('sets hasRrule=true for an all-day VEVENT with RRULE (all-day recurring)', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_RECURRING_ALLDAY, etag: '"etag-rrule-allday"', url: '/rrule-allday.ics' },
|
||||
{
|
||||
data: SAMPLE_VEVENT_RECURRING_ALLDAY,
|
||||
etag: '"etag-rrule-allday"',
|
||||
url: '/rrule-allday.ics',
|
||||
},
|
||||
]),
|
||||
}
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test Calendar',
|
||||
ctag: 'ctag-v1',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2)
|
||||
const eventValuesArg = mockValues.mock.calls[1][0]
|
||||
expect(eventValuesArg.hasRrule).toBe(true)
|
||||
})
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2);
|
||||
const eventValuesArg = mockValues.mock.calls[1][0];
|
||||
expect(eventValuesArg.hasRrule).toBe(true);
|
||||
});
|
||||
|
||||
it('sets hasRrule=false for a non-recurring timed VEVENT', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-oneoff"', url: '/oneoff.ics' },
|
||||
]),
|
||||
}
|
||||
fetchCalendarObjects: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-oneoff"', url: '/oneoff.ics' },
|
||||
]),
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test Calendar',
|
||||
ctag: 'ctag-v1',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2)
|
||||
const eventValuesArg = mockValues.mock.calls[1][0]
|
||||
expect(eventValuesArg.hasRrule).toBe(false)
|
||||
})
|
||||
expect(mockInsert).toHaveBeenCalledTimes(2);
|
||||
const eventValuesArg = mockValues.mock.calls[1][0];
|
||||
expect(eventValuesArg.hasRrule).toBe(false);
|
||||
});
|
||||
|
||||
it('includes hasRrule in onDuplicateKeyUpdate set so re-syncs self-heal the flag', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-v2"', url: '/rrule.ics' },
|
||||
]),
|
||||
}
|
||||
fetchCalendarObjects: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_RECURRING_TIMED, etag: '"etag-v2"', url: '/rrule.ics' },
|
||||
]),
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test Calendar',
|
||||
ctag: 'ctag-v2',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
|
||||
// The second onDuplicateKeyUpdate call is for the event upsert
|
||||
const eventUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[1][0]
|
||||
expect(eventUpdateArg.set).toHaveProperty('hasRrule', true)
|
||||
})
|
||||
const eventUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[1][0];
|
||||
expect(eventUpdateArg.set).toHaveProperty('hasRrule', true);
|
||||
});
|
||||
|
||||
it('BUG B: scopes the calendar-row select to (userId, url), not url alone', async () => {
|
||||
// Capture the predicate passed to db.select().from(calendars).where(...).limit(1).
|
||||
const capturedWhere: unknown[] = []
|
||||
const capturedWhere: unknown[] = [];
|
||||
mockWhere.mockImplementation((pred: unknown) => {
|
||||
capturedWhere.push(pred)
|
||||
return { limit: mockLimit }
|
||||
})
|
||||
capturedWhere.push(pred);
|
||||
return { limit: mockLimit };
|
||||
});
|
||||
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) }
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) };
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test',
|
||||
ctag: 'v1',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 99)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 99);
|
||||
|
||||
expect(capturedWhere.length).toBeGreaterThan(0)
|
||||
expect(capturedWhere.length).toBeGreaterThan(0);
|
||||
const serialized = JSON.stringify(capturedWhere[0], (_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('BUG B: calendar upsert is idempotent — onDuplicateKeyUpdate fires for the calendar row', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) }
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
const mockClient = { fetchCalendarObjects: vi.fn().mockResolvedValue([]) };
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test',
|
||||
ctag: 'v1',
|
||||
syncToken: null,
|
||||
}
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
};
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
// The calendar insert (call 0) must use onDuplicateKeyUpdate so the (userId,url)
|
||||
// unique key makes re-polls update-in-place instead of inserting duplicate rows.
|
||||
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalled()
|
||||
const calUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[0][0]
|
||||
expect(calUpdateArg.set).toHaveProperty('ctag')
|
||||
})
|
||||
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalled();
|
||||
const calUpdateArg = mockOnDuplicateKeyUpdate.mock.calls[0][0];
|
||||
expect(calUpdateArg.set).toHaveProperty('ctag');
|
||||
});
|
||||
|
||||
it('updates the calendar ctag/syncToken after a successful sync', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test Calendar',
|
||||
ctag: 'new-ctag-123',
|
||||
syncToken: 'sync-token-abc',
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
|
||||
// Calendar insert values must include the new ctag and syncToken
|
||||
const calValuesArg = mockValues.mock.calls[0][0]
|
||||
expect(calValuesArg.ctag).toBe('new-ctag-123')
|
||||
expect(calValuesArg.syncToken).toBe('sync-token-abc')
|
||||
})
|
||||
const calValuesArg = mockValues.mock.calls[0][0];
|
||||
expect(calValuesArg.ctag).toBe('new-ctag-123');
|
||||
expect(calValuesArg.syncToken).toBe('sync-token-abc');
|
||||
});
|
||||
|
||||
// Regression: deletes must be reconciled out of the cache. Before this fix,
|
||||
// syncCalendar only upserted present events, so a deleted event lingered in
|
||||
// calendar_events forever and the UI showed a ghost that "wouldn't delete".
|
||||
it('prunes cached events whose uid is absent from the server (delete reconciliation)', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
// Server returns ONE timed event; any other cached uid for this calendar must be pruned.
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.ics' },
|
||||
]),
|
||||
}
|
||||
fetchCalendarObjects: vi
|
||||
.fn()
|
||||
.mockResolvedValue([
|
||||
{ data: SAMPLE_VEVENT_TIMED, etag: '"etag-timed"', url: '/cal/timed.ics' },
|
||||
]),
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test Calendar',
|
||||
ctag: 'ctag-v2',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
|
||||
// A prune DELETE must run, scoped by calendarId AND excluding the seen uid(s).
|
||||
expect(mockDelete).toHaveBeenCalledTimes(1)
|
||||
expect(mockDeleteWhere).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockDelete).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteWhere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('prunes the entire calendar cache when the server returns zero events', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test Calendar',
|
||||
ctag: 'ctag-empty',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1);
|
||||
|
||||
// Empty server result → prune-all DELETE (scoped to this calendar id only).
|
||||
expect(mockDelete).toHaveBeenCalledTimes(1)
|
||||
expect(mockDeleteWhere).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(mockDelete).toHaveBeenCalledTimes(1);
|
||||
expect(mockDeleteWhere).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// NEW-WR-01: When the server returns zero events (whole-cache clear), the
|
||||
// onChanges callback must still receive a 'delete' change for each cached row
|
||||
@@ -372,7 +388,7 @@ describe('syncCalendar', () => {
|
||||
// ran inside the seenUids.length > 0 branch, so bulk/clear deletions silently
|
||||
// dropped all delete change events.
|
||||
it('NEW-WR-01: emits delete change events for each cached row when server returns zero events', async () => {
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js')
|
||||
const { syncCalendar } = await import('../../src/broker/sync.js');
|
||||
|
||||
// First mockWhere call: calendar row lookup (ends in .limit(1)) → { limit: mockLimit }
|
||||
// Second mockWhere call: pending-delete pre-capture (direct await, no .limit()) →
|
||||
@@ -383,32 +399,33 @@ describe('syncCalendar', () => {
|
||||
// 1. db.select().from(calendars).where(...).limit(1) — calendar row lookup
|
||||
// 2. db.select({uid,title}).from(calendarEvents).where(...) — pending-delete capture (awaited directly)
|
||||
mockWhere
|
||||
.mockReturnValueOnce({ limit: mockLimit }) // call 1: calendar row lookup
|
||||
.mockResolvedValueOnce([ // call 2: pending-delete capture
|
||||
.mockReturnValueOnce({ limit: mockLimit }) // call 1: calendar row lookup
|
||||
.mockResolvedValueOnce([
|
||||
// call 2: pending-delete capture
|
||||
{ uid: 'uid-to-delete-1', title: 'Event A' },
|
||||
{ uid: 'uid-to-delete-2', title: 'Event B' },
|
||||
])
|
||||
]);
|
||||
|
||||
const mockClient = {
|
||||
fetchCalendarObjects: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
};
|
||||
const mockDavCal = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
displayName: 'Test Calendar',
|
||||
ctag: 'ctag-empty',
|
||||
syncToken: null,
|
||||
}
|
||||
};
|
||||
|
||||
const collectedChanges: import('../../src/lib/eventChangeDispatcher.js').EventChange[] = []
|
||||
const collectedChanges: import('../../src/lib/eventChangeDispatcher.js').EventChange[] = [];
|
||||
const onChanges = (changes: import('../../src/lib/eventChangeDispatcher.js').EventChange[]) => {
|
||||
collectedChanges.push(...changes)
|
||||
}
|
||||
collectedChanges.push(...changes);
|
||||
};
|
||||
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1, onChanges)
|
||||
await syncCalendar(mockClient as never, mockDavCal as never, 1, onChanges);
|
||||
|
||||
// The callback must have received one delete change per cached row.
|
||||
expect(collectedChanges).toHaveLength(2)
|
||||
expect(collectedChanges[0]).toMatchObject({ uid: 'uid-to-delete-1', operation: 'delete' })
|
||||
expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' })
|
||||
})
|
||||
})
|
||||
expect(collectedChanges).toHaveLength(2);
|
||||
expect(collectedChanges[0]).toMatchObject({ uid: 'uid-to-delete-1', operation: 'delete' });
|
||||
expect(collectedChanges[1]).toMatchObject({ uid: 'uid-to-delete-2', operation: 'delete' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,12 +12,12 @@
|
||||
* They will turn GREEN in Plan 03-02 when the implementation is added.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
// This import fails (RED) — broker/vevent.ts does not exist yet.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore intentional RED import
|
||||
import { buildVeventString } from '../../src/broker/vevent.js'
|
||||
import { buildVeventString } from '../../src/broker/vevent.js';
|
||||
|
||||
describe('buildVeventString', () => {
|
||||
it('produces a VCALENDAR string containing a VEVENT for a timed event', () => {
|
||||
@@ -26,16 +26,16 @@ describe('buildVeventString', () => {
|
||||
allDay: false,
|
||||
dtstart: new Date('2026-06-10T09:00:00Z'),
|
||||
dtend: new Date('2026-06-10T09:30:00Z'),
|
||||
})
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('uid')
|
||||
expect(result).toHaveProperty('icsString')
|
||||
expect(result.icsString).toContain('BEGIN:VCALENDAR')
|
||||
expect(result.icsString).toContain('BEGIN:VEVENT')
|
||||
expect(result.icsString).toContain('SUMMARY:Team standup')
|
||||
expect(result.icsString).toContain('END:VEVENT')
|
||||
expect(result.icsString).toContain('END:VCALENDAR')
|
||||
})
|
||||
expect(result).toHaveProperty('uid');
|
||||
expect(result).toHaveProperty('icsString');
|
||||
expect(result.icsString).toContain('BEGIN:VCALENDAR');
|
||||
expect(result.icsString).toContain('BEGIN:VEVENT');
|
||||
expect(result.icsString).toContain('SUMMARY:Team standup');
|
||||
expect(result.icsString).toContain('END:VEVENT');
|
||||
expect(result.icsString).toContain('END:VCALENDAR');
|
||||
});
|
||||
|
||||
it('produces DTSTART with Z suffix (UTC) for a timed event — not TZID', () => {
|
||||
const result = buildVeventString({
|
||||
@@ -43,12 +43,12 @@ describe('buildVeventString', () => {
|
||||
allDay: false,
|
||||
dtstart: new Date('2026-06-10T14:00:00Z'),
|
||||
dtend: new Date('2026-06-10T15:00:00Z'),
|
||||
})
|
||||
});
|
||||
|
||||
// D-13 timed contract: DATETIME in UTC → 'Z' suffix, no TZID
|
||||
expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/)
|
||||
expect(result.icsString).not.toMatch(/DTSTART;TZID=/)
|
||||
})
|
||||
expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/);
|
||||
expect(result.icsString).not.toMatch(/DTSTART;TZID=/);
|
||||
});
|
||||
|
||||
it('produces DTSTART as DATE (no time, no TZID) for an all-day event', () => {
|
||||
const result = buildVeventString({
|
||||
@@ -56,16 +56,16 @@ describe('buildVeventString', () => {
|
||||
allDay: true,
|
||||
dtstart: '2026-06-15',
|
||||
dtend: '2026-06-16',
|
||||
})
|
||||
});
|
||||
|
||||
// D-13 all-day contract: VALUE=DATE, no time component, no TZID
|
||||
// ical.js represents DATE as DTSTART;VALUE=DATE:YYYYMMDD
|
||||
expect(result.icsString).toMatch(/DTSTART[^:]*:20260615/)
|
||||
expect(result.icsString).toMatch(/DTSTART[^:]*:20260615/);
|
||||
// Must NOT contain a time component (no 'T' after the date)
|
||||
expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260615T/)
|
||||
expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260615T/);
|
||||
// Must NOT contain TZID on the DTSTART property
|
||||
expect(result.icsString).not.toMatch(/DTSTART;TZID=/)
|
||||
})
|
||||
expect(result.icsString).not.toMatch(/DTSTART;TZID=/);
|
||||
});
|
||||
|
||||
it('includes an RRULE property when rruleString is provided (CAL-07)', () => {
|
||||
const result = buildVeventString({
|
||||
@@ -74,10 +74,10 @@ describe('buildVeventString', () => {
|
||||
dtstart: new Date('2026-06-09T10:00:00Z'),
|
||||
dtend: new Date('2026-06-09T11:00:00Z'),
|
||||
rruleString: 'FREQ=WEEKLY;BYDAY=MO',
|
||||
})
|
||||
});
|
||||
|
||||
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;BYDAY=MO')
|
||||
})
|
||||
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;BYDAY=MO');
|
||||
});
|
||||
|
||||
it('does NOT include RRULE when rruleString is omitted', () => {
|
||||
const result = buildVeventString({
|
||||
@@ -85,10 +85,10 @@ describe('buildVeventString', () => {
|
||||
allDay: false,
|
||||
dtstart: new Date('2026-06-10T12:00:00Z'),
|
||||
dtend: new Date('2026-06-10T13:00:00Z'),
|
||||
})
|
||||
});
|
||||
|
||||
expect(result.icsString).not.toContain('RRULE:')
|
||||
})
|
||||
expect(result.icsString).not.toContain('RRULE:');
|
||||
});
|
||||
|
||||
// D-06: RRULE COUNT — verified ical.js 2.2.1 output
|
||||
it('serializes COUNT in RRULE for a timed event (D-06)', () => {
|
||||
@@ -98,9 +98,9 @@ describe('buildVeventString', () => {
|
||||
dtstart: new Date('2026-06-10T09:00:00Z'),
|
||||
dtend: new Date('2026-06-10T10:00:00Z'),
|
||||
rruleString: 'FREQ=WEEKLY;COUNT=5',
|
||||
})
|
||||
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;COUNT=5')
|
||||
})
|
||||
});
|
||||
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;COUNT=5');
|
||||
});
|
||||
|
||||
// D-06: RRULE UNTIL DATE form — all-day event must NOT contain T235959Z
|
||||
it('serializes UNTIL as DATE form for all-day events (D-06)', () => {
|
||||
@@ -110,10 +110,10 @@ describe('buildVeventString', () => {
|
||||
dtstart: '2026-06-10',
|
||||
dtend: '2026-06-11',
|
||||
rruleString: 'FREQ=DAILY;UNTIL=20260630',
|
||||
})
|
||||
expect(result.icsString).toContain('RRULE:FREQ=DAILY;UNTIL=20260630')
|
||||
expect(result.icsString).not.toContain('T235959Z')
|
||||
})
|
||||
});
|
||||
expect(result.icsString).toContain('RRULE:FREQ=DAILY;UNTIL=20260630');
|
||||
expect(result.icsString).not.toContain('T235959Z');
|
||||
});
|
||||
|
||||
// D-06: RRULE UNTIL DATETIME UTC form — timed event
|
||||
it('serializes UNTIL as DATETIME UTC form for timed events (D-06)', () => {
|
||||
@@ -123,23 +123,23 @@ describe('buildVeventString', () => {
|
||||
dtstart: new Date('2026-06-10T09:00:00Z'),
|
||||
dtend: new Date('2026-06-10T10:00:00Z'),
|
||||
rruleString: 'FREQ=WEEKLY;UNTIL=20260630T235959Z',
|
||||
})
|
||||
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z')
|
||||
})
|
||||
});
|
||||
expect(result.icsString).toContain('RRULE:FREQ=WEEKLY;UNTIL=20260630T235959Z');
|
||||
});
|
||||
|
||||
it('uses the provided uid when given', () => {
|
||||
const uid = 'custom-uid-001@familysync'
|
||||
const uid = 'custom-uid-001@familysync';
|
||||
const result = buildVeventString({
|
||||
uid,
|
||||
summary: 'Test event',
|
||||
allDay: false,
|
||||
dtstart: new Date('2026-06-10T09:00:00Z'),
|
||||
dtend: new Date('2026-06-10T10:00:00Z'),
|
||||
})
|
||||
});
|
||||
|
||||
expect(result.uid).toBe(uid)
|
||||
expect(result.icsString).toContain(`UID:${uid}`)
|
||||
})
|
||||
expect(result.uid).toBe(uid);
|
||||
expect(result.icsString).toContain(`UID:${uid}`);
|
||||
});
|
||||
|
||||
it('generates a uid when none is provided', () => {
|
||||
const result = buildVeventString({
|
||||
@@ -147,12 +147,12 @@ describe('buildVeventString', () => {
|
||||
allDay: false,
|
||||
dtstart: new Date('2026-06-10T09:00:00Z'),
|
||||
dtend: new Date('2026-06-10T10:00:00Z'),
|
||||
})
|
||||
});
|
||||
|
||||
expect(result.uid).toBeTruthy()
|
||||
expect(result.uid.length).toBeGreaterThan(10)
|
||||
})
|
||||
})
|
||||
expect(result.uid).toBeTruthy();
|
||||
expect(result.uid.length).toBeGreaterThan(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildVeventString — D-13 form-parsed contract', () => {
|
||||
it('timed event: produces BEGIN:VCALENDAR, SUMMARY, UID, timed DTSTART (Z suffix), and DTEND', () => {
|
||||
@@ -163,16 +163,16 @@ describe('buildVeventString — D-13 form-parsed contract', () => {
|
||||
allDay: false,
|
||||
dtstart: new Date('2026-06-10T12:00:00Z'),
|
||||
dtend: new Date('2026-06-10T13:00:00Z'),
|
||||
})
|
||||
});
|
||||
|
||||
expect(result.icsString).toContain('BEGIN:VCALENDAR')
|
||||
expect(result.icsString).toContain('SUMMARY:Lunch')
|
||||
expect(result.icsString).toContain('UID:u1@familysync')
|
||||
expect(result.icsString).toContain('BEGIN:VCALENDAR');
|
||||
expect(result.icsString).toContain('SUMMARY:Lunch');
|
||||
expect(result.icsString).toContain('UID:u1@familysync');
|
||||
// D-13 timed contract: DATETIME in UTC → 'Z' suffix, no TZID
|
||||
expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/)
|
||||
expect(result.icsString).toMatch(/DTSTART:\d{8}T\d{6}Z/);
|
||||
// DTEND must be present
|
||||
expect(result.icsString).toMatch(/DTEND:\d{8}T\d{6}Z/)
|
||||
})
|
||||
expect(result.icsString).toMatch(/DTEND:\d{8}T\d{6}Z/);
|
||||
});
|
||||
|
||||
it('single-day all-day event: DTSTART is DATE format and DTEND = DTSTART + 1 day (RFC-5545 exclusive end, WR-04)', () => {
|
||||
// Simulates a single-day all-day event where start and end are the same calendar day.
|
||||
@@ -182,17 +182,17 @@ describe('buildVeventString — D-13 form-parsed contract', () => {
|
||||
allDay: true,
|
||||
dtstart: '2026-06-10',
|
||||
dtend: '2026-06-10',
|
||||
})
|
||||
});
|
||||
|
||||
// DTSTART must be DATE format (no time component, no TZID) — D-13 all-day contract
|
||||
expect(result.icsString).toMatch(/DTSTART[^:]*:20260610/)
|
||||
expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260610T/)
|
||||
expect(result.icsString).toMatch(/DTSTART[^:]*:20260610/);
|
||||
expect(result.icsString).not.toMatch(/DTSTART[^:]*:20260610T/);
|
||||
|
||||
// WR-04: DTEND must be DTSTART + 1 day (RFC-5545 exclusive end)
|
||||
expect(result.icsString).toMatch(/DTEND[^:]*:20260611/)
|
||||
expect(result.icsString).toMatch(/DTEND[^:]*:20260611/);
|
||||
// DTEND date string must NOT equal DTSTART date string (owning-boundary assertion)
|
||||
const dtendMatch = result.icsString.match(/DTEND[^:]*:(\d{8})/)
|
||||
const dtstartMatch = result.icsString.match(/DTSTART[^:]*:(\d{8})/)
|
||||
expect(dtendMatch?.[1]).not.toBe(dtstartMatch?.[1])
|
||||
})
|
||||
})
|
||||
const dtendMatch = result.icsString.match(/DTEND[^:]*:(\d{8})/);
|
||||
const dtstartMatch = result.icsString.match(/DTSTART[^:]*:(\d{8})/);
|
||||
expect(dtendMatch?.[1]).not.toBe(dtstartMatch?.[1]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
* They will turn GREEN in Plan 03-02 when the implementation is added.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// This import fails (RED) — broker/write.ts does not exist yet.
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
@@ -20,51 +20,68 @@ import {
|
||||
createCalendarEvent,
|
||||
updateCalendarEvent,
|
||||
deleteCalendarEvent,
|
||||
} from '../../src/broker/write.js'
|
||||
} from '../../src/broker/write.js';
|
||||
|
||||
const makeMockResponse = (status: number): Response =>
|
||||
({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response
|
||||
({ status, ok: status >= 200 && status < 300, headers: new Headers() }) as unknown as Response;
|
||||
|
||||
describe('createCalendarEvent', () => {
|
||||
it('calls client.createCalendarObject with uid.ics filename', async () => {
|
||||
const mockClient = {
|
||||
createCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(201)),
|
||||
}
|
||||
const mockCalendar = { url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/' }
|
||||
const uid = 'abc-123@familysync'
|
||||
const icsString = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR'
|
||||
};
|
||||
const mockCalendar = {
|
||||
url: 'https://caldav.fastmail.com/dav/calendars/user/test@fm.com/Default/',
|
||||
};
|
||||
const uid = 'abc-123@familysync';
|
||||
const icsString = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR';
|
||||
|
||||
const result = await createCalendarEvent(mockClient as never, mockCalendar as never, uid, icsString)
|
||||
const result = await createCalendarEvent(
|
||||
mockClient as never,
|
||||
mockCalendar as never,
|
||||
uid,
|
||||
icsString,
|
||||
);
|
||||
|
||||
expect(mockClient.createCalendarObject).toHaveBeenCalledWith({
|
||||
calendar: mockCalendar,
|
||||
filename: `${uid}.ics`,
|
||||
iCalString: icsString,
|
||||
})
|
||||
expect(result.status).toBe(201)
|
||||
})
|
||||
});
|
||||
expect(result.status).toBe(201);
|
||||
});
|
||||
|
||||
it('returns the raw Response from client', async () => {
|
||||
const mockClient = {
|
||||
createCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)),
|
||||
}
|
||||
const mockCalendar = { url: 'https://caldav.fastmail.com/' }
|
||||
const result = await createCalendarEvent(mockClient as never, mockCalendar as never, 'uid@fs', 'ICS')
|
||||
expect(result).toBeDefined()
|
||||
expect(result.status).toBe(204)
|
||||
})
|
||||
})
|
||||
};
|
||||
const mockCalendar = { url: 'https://caldav.fastmail.com/' };
|
||||
const result = await createCalendarEvent(
|
||||
mockClient as never,
|
||||
mockCalendar as never,
|
||||
'uid@fs',
|
||||
'ICS',
|
||||
);
|
||||
expect(result).toBeDefined();
|
||||
expect(result.status).toBe(204);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateCalendarEvent', () => {
|
||||
it('calls client.updateCalendarObject with etag in calendarObject (If-Match)', async () => {
|
||||
const mockClient = {
|
||||
updateCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)),
|
||||
}
|
||||
const calendarObjectUrl = 'https://caldav.fastmail.com/dav/calendars/user/test/Default/uid.ics'
|
||||
const icsString = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR'
|
||||
const etag = '"etag-123"'
|
||||
};
|
||||
const calendarObjectUrl = 'https://caldav.fastmail.com/dav/calendars/user/test/Default/uid.ics';
|
||||
const icsString = 'BEGIN:VCALENDAR\r\nEND:VCALENDAR';
|
||||
const etag = '"etag-123"';
|
||||
|
||||
const result = await updateCalendarEvent(mockClient as never, calendarObjectUrl, icsString, etag)
|
||||
const result = await updateCalendarEvent(
|
||||
mockClient as never,
|
||||
calendarObjectUrl,
|
||||
icsString,
|
||||
etag,
|
||||
);
|
||||
|
||||
expect(mockClient.updateCalendarObject).toHaveBeenCalledWith({
|
||||
calendarObject: {
|
||||
@@ -72,30 +89,35 @@ describe('updateCalendarEvent', () => {
|
||||
data: icsString,
|
||||
etag,
|
||||
},
|
||||
})
|
||||
expect(result.status).toBe(204)
|
||||
})
|
||||
});
|
||||
expect(result.status).toBe(204);
|
||||
});
|
||||
|
||||
it('passes empty string as etag when etag is null (safe for If-Match)', async () => {
|
||||
const mockClient = {
|
||||
updateCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)),
|
||||
}
|
||||
await updateCalendarEvent(mockClient as never, 'https://caldav.fastmail.com/uid.ics', 'ICS', null)
|
||||
};
|
||||
await updateCalendarEvent(
|
||||
mockClient as never,
|
||||
'https://caldav.fastmail.com/uid.ics',
|
||||
'ICS',
|
||||
null,
|
||||
);
|
||||
|
||||
const callArg = mockClient.updateCalendarObject.mock.calls[0][0]
|
||||
expect(callArg.calendarObject.etag).toBe('')
|
||||
})
|
||||
})
|
||||
const callArg = mockClient.updateCalendarObject.mock.calls[0][0];
|
||||
expect(callArg.calendarObject.etag).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteCalendarEvent', () => {
|
||||
it('calls client.deleteCalendarObject with etag in calendarObject (If-Match)', async () => {
|
||||
const mockClient = {
|
||||
deleteCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)),
|
||||
}
|
||||
const calendarObjectUrl = 'https://caldav.fastmail.com/dav/calendars/user/test/Default/uid.ics'
|
||||
const etag = '"etag-456"'
|
||||
};
|
||||
const calendarObjectUrl = 'https://caldav.fastmail.com/dav/calendars/user/test/Default/uid.ics';
|
||||
const etag = '"etag-456"';
|
||||
|
||||
const result = await deleteCalendarEvent(mockClient as never, calendarObjectUrl, etag)
|
||||
const result = await deleteCalendarEvent(mockClient as never, calendarObjectUrl, etag);
|
||||
|
||||
expect(mockClient.deleteCalendarObject).toHaveBeenCalledWith({
|
||||
calendarObject: {
|
||||
@@ -103,17 +125,17 @@ describe('deleteCalendarEvent', () => {
|
||||
data: '',
|
||||
etag,
|
||||
},
|
||||
})
|
||||
expect(result.status).toBe(204)
|
||||
})
|
||||
});
|
||||
expect(result.status).toBe(204);
|
||||
});
|
||||
|
||||
it('passes empty string as etag when etag is null', async () => {
|
||||
const mockClient = {
|
||||
deleteCalendarObject: vi.fn().mockResolvedValue(makeMockResponse(204)),
|
||||
}
|
||||
await deleteCalendarEvent(mockClient as never, 'https://caldav.fastmail.com/uid.ics', null)
|
||||
};
|
||||
await deleteCalendarEvent(mockClient as never, 'https://caldav.fastmail.com/uid.ics', null);
|
||||
|
||||
const callArg = mockClient.deleteCalendarObject.mock.calls[0][0]
|
||||
expect(callArg.calendarObject.etag).toBe('')
|
||||
})
|
||||
})
|
||||
const callArg = mockClient.deleteCalendarObject.mock.calls[0][0];
|
||||
expect(callArg.calendarObject.etag).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
Vendored
+3
-2
@@ -9,7 +9,8 @@
|
||||
* and inlined here so tests are deterministic and offline-safe.
|
||||
*/
|
||||
export const TEST_VAPID = {
|
||||
publicKey: 'BIr9cwAc5L5ZBuY6RazVpjZfIzaAAY_dXDvaMOgM8_nGO8HSyr-WsEoxsmvhG9hWJDK-Mn07rjAFnr9S8fa2w48',
|
||||
publicKey:
|
||||
'BIr9cwAc5L5ZBuY6RazVpjZfIzaAAY_dXDvaMOgM8_nGO8HSyr-WsEoxsmvhG9hWJDK-Mn07rjAFnr9S8fa2w48',
|
||||
privateKey: 'IjVM8QjjFqDI9_-lhJDmG9yhPpgcrEtKmM-GP1DLiyc',
|
||||
subject: 'mailto:test@familysync.test',
|
||||
} as const
|
||||
} as const;
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
* - GET /health returns 503 if the DB round-trip throws
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
|
||||
// vi.mock is hoisted to the top of the module by Vitest — defining it here is correct.
|
||||
// The factory is called before any test runs.
|
||||
@@ -14,25 +14,25 @@ vi.mock('../src/db/client.js', () => ({
|
||||
db: {
|
||||
execute: vi.fn().mockResolvedValue([[{ '1': 1 }]]),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
describe('GET /health', () => {
|
||||
it('returns 200 with { ok: true, db: "up" } when DB round-trip succeeds', async () => {
|
||||
const { app } = await import('../src/index.js')
|
||||
const res = await app.request('/health')
|
||||
expect(res.status).toBe(200)
|
||||
const body = await res.json() as { ok: boolean; db: string }
|
||||
expect(body.ok).toBe(true)
|
||||
expect(body.db).toBe('up')
|
||||
})
|
||||
const { app } = await import('../src/index.js');
|
||||
const res = await app.request('/health');
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean; db: string };
|
||||
expect(body.ok).toBe(true);
|
||||
expect(body.db).toBe('up');
|
||||
});
|
||||
|
||||
it('returns 503 when DB round-trip throws', async () => {
|
||||
const { db } = await import('../src/db/client.js')
|
||||
const { db } = await import('../src/db/client.js');
|
||||
// Temporarily override execute to throw
|
||||
vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed'))
|
||||
vi.mocked(db.execute).mockRejectedValueOnce(new Error('DB connection failed'));
|
||||
|
||||
const { app } = await import('../src/index.js')
|
||||
const res = await app.request('/health')
|
||||
expect(res.status).toBe(503)
|
||||
})
|
||||
})
|
||||
const { app } = await import('../src/index.js');
|
||||
const res = await app.request('/health');
|
||||
expect(res.status).toBe(503);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
// Re-export vi for convenience in test files
|
||||
export { vi } from 'vitest'
|
||||
export { vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* Creates a minimal mock for the Drizzle `db` singleton.
|
||||
@@ -27,7 +27,7 @@ export function createMockDb() {
|
||||
onDuplicateKeyUpdate: vi.fn().mockResolvedValue([{ id: 1 }]),
|
||||
$returningId: vi.fn().mockResolvedValue([{ id: 1 }]),
|
||||
execute: vi.fn().mockResolvedValue([]),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -42,7 +42,7 @@ DTSTART:20260615T100000Z
|
||||
DTEND:20260615T110000Z
|
||||
SUMMARY:Test Timed Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`
|
||||
END:VCALENDAR`;
|
||||
|
||||
/**
|
||||
* Sample VEVENT string for broker tests — an all-day event (Plan 03).
|
||||
@@ -56,7 +56,7 @@ DTSTART;VALUE=DATE:20260615
|
||||
DTEND;VALUE=DATE:20260616
|
||||
SUMMARY:Test All-Day Event
|
||||
END:VEVENT
|
||||
END:VCALENDAR`
|
||||
END:VCALENDAR`;
|
||||
|
||||
/**
|
||||
* Sample VEVENT string — a timed recurring event (RRULE:FREQ=WEEKLY).
|
||||
@@ -71,7 +71,7 @@ DTSTART:20240101T100000Z
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
SUMMARY:Weekly Monday Meeting
|
||||
END:VEVENT
|
||||
END:VCALENDAR`
|
||||
END:VCALENDAR`;
|
||||
|
||||
/**
|
||||
* Sample VEVENT string — an all-day recurring event (RRULE:FREQ=YEARLY).
|
||||
@@ -87,4 +87,4 @@ DTSTART;VALUE=DATE:20240615
|
||||
RRULE:FREQ=YEARLY
|
||||
SUMMARY:Annual Birthday
|
||||
END:VEVENT
|
||||
END:VCALENDAR`
|
||||
END:VCALENDAR`;
|
||||
|
||||
@@ -12,19 +12,19 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/eventChangeDispatcher.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock DB — the factory is hoisted; per-test configuration uses mockReturnValueOnce
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
db: {
|
||||
select: vi.fn(),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock pushDispatcher
|
||||
vi.mock('../../src/lib/pushDispatcher.js', () => ({
|
||||
dispatchPush: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helper: configure db.select for two sequential calls used by dispatchEventChange.
|
||||
@@ -46,9 +46,11 @@ function setupDbMock(
|
||||
.mockReturnValueOnce({
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockReturnValue({
|
||||
limit: vi.fn().mockResolvedValue(
|
||||
actorDisplayName !== null ? [{ displayName: actorDisplayName }] : [],
|
||||
),
|
||||
limit: vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
actorDisplayName !== null ? [{ displayName: actorDisplayName }] : [],
|
||||
),
|
||||
}),
|
||||
}),
|
||||
})
|
||||
@@ -57,40 +59,52 @@ function setupDbMock(
|
||||
from: vi.fn().mockReturnValue({
|
||||
where: vi.fn().mockResolvedValue(subRows),
|
||||
}),
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
describe('eventChangeDispatcher — trigger conditions (D-04)', () => {
|
||||
beforeEach(() => {
|
||||
// resetAllMocks clears call history AND resets mockReturnValueOnce queues,
|
||||
// preventing leaked once-queues from test N polluting test N+1.
|
||||
vi.resetAllMocks()
|
||||
vi.resetModules()
|
||||
})
|
||||
vi.resetAllMocks();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
it('dispatches for a new event (operation=create)', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
const otherUserSub = { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' }
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub])
|
||||
const otherUserSub = {
|
||||
id: 2,
|
||||
userId: 2,
|
||||
endpoint: 'https://push.example.com/sub/2',
|
||||
p256dh: 'x',
|
||||
auth: 'y',
|
||||
};
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub]);
|
||||
|
||||
await dispatchEventChange(
|
||||
{ uid: 'new-event-uid', title: 'Doctor appointment', operation: 'create' },
|
||||
/* actorUserId */ 1,
|
||||
)
|
||||
);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('dispatches for an event with a title change', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
const otherUserSub = { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' }
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub])
|
||||
const otherUserSub = {
|
||||
id: 2,
|
||||
userId: 2,
|
||||
endpoint: 'https://push.example.com/sub/2',
|
||||
p256dh: 'x',
|
||||
auth: 'y',
|
||||
};
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub]);
|
||||
|
||||
await dispatchEventChange(
|
||||
{
|
||||
@@ -100,21 +114,21 @@ describe('eventChangeDispatcher — trigger conditions (D-04)', () => {
|
||||
changedFields: ['title'],
|
||||
},
|
||||
/* actorUserId */ 1,
|
||||
)
|
||||
);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT dispatch for a description-only edit (D-04)', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
// isMeaningfulChange returns false before DB is queried — select won't be called.
|
||||
// Set up mock anyway as a no-op guard.
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [
|
||||
{ id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' },
|
||||
])
|
||||
]);
|
||||
|
||||
await dispatchEventChange(
|
||||
{
|
||||
@@ -124,64 +138,76 @@ describe('eventChangeDispatcher — trigger conditions (D-04)', () => {
|
||||
changedFields: ['description'], // description-only — must NOT fire
|
||||
},
|
||||
/* actorUserId */ 1,
|
||||
)
|
||||
);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('excludes the actor user subscriptions from dispatch (D-03)', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
// DB's ne() filter already excludes the actor; simulate that the DB returns
|
||||
// only the actor's own sub (userId=1) to test the application-level guard.
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [
|
||||
{ id: 1, userId: 1, endpoint: 'https://push.example.com/sub/1', p256dh: 'x', auth: 'y' },
|
||||
])
|
||||
]);
|
||||
|
||||
await dispatchEventChange(
|
||||
{ uid: 'event-uid-3', title: 'Soccer practice', operation: 'create' },
|
||||
/* actorUserId */ 1, // actor is userId=1 — their subscription must be excluded
|
||||
)
|
||||
);
|
||||
|
||||
// No subscriptions remain after excluding the actor — nothing dispatched
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('names the actor in the notification title (IN-01 / D-02/D-03)', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
const otherUserSub = { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' }
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub])
|
||||
const otherUserSub = {
|
||||
id: 2,
|
||||
userId: 2,
|
||||
endpoint: 'https://push.example.com/sub/2',
|
||||
p256dh: 'x',
|
||||
auth: 'y',
|
||||
};
|
||||
setupDbMock(vi.mocked(db), 'Lucas', [otherUserSub]);
|
||||
|
||||
await dispatchEventChange(
|
||||
{ uid: 'event-uid-4', title: 'Dentist', operation: 'create' },
|
||||
/* actorUserId */ 1,
|
||||
)
|
||||
);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled()
|
||||
const calledWith = vi.mocked(dispatchPush).mock.calls[0][1]
|
||||
expect(calledWith.title).toContain('Lucas')
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled();
|
||||
const calledWith = vi.mocked(dispatchPush).mock.calls[0][1];
|
||||
expect(calledWith.title).toContain('Lucas');
|
||||
});
|
||||
|
||||
it('falls back to "A family member" when actor row is missing (IN-01)', async () => {
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js')
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
const { dispatchEventChange } = await import('../../src/lib/eventChangeDispatcher.js');
|
||||
|
||||
const otherUserSub = { id: 2, userId: 2, endpoint: 'https://push.example.com/sub/2', p256dh: 'x', auth: 'y' }
|
||||
setupDbMock(vi.mocked(db), null, [otherUserSub]) // actor row absent → empty array
|
||||
const otherUserSub = {
|
||||
id: 2,
|
||||
userId: 2,
|
||||
endpoint: 'https://push.example.com/sub/2',
|
||||
p256dh: 'x',
|
||||
auth: 'y',
|
||||
};
|
||||
setupDbMock(vi.mocked(db), null, [otherUserSub]); // actor row absent → empty array
|
||||
|
||||
await dispatchEventChange(
|
||||
{ uid: 'event-uid-5', title: 'Soccer', operation: 'create' },
|
||||
/* actorUserId */ 99, // non-existent user
|
||||
)
|
||||
);
|
||||
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled()
|
||||
const calledWith = vi.mocked(dispatchPush).mock.calls[0][1]
|
||||
expect(calledWith.title).toContain('A family member')
|
||||
})
|
||||
})
|
||||
expect(vi.mocked(dispatchPush)).toHaveBeenCalled();
|
||||
const calledWith = vi.mocked(dispatchPush).mock.calls[0][1];
|
||||
expect(calledWith.title).toContain('A family member');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,81 +11,87 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/listAccess.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { db } from '../../src/db/client.js'
|
||||
import { users, lists, listShares } from '../../src/db/schema.js'
|
||||
import { getAccessibleListIds } from '../../src/lib/listAccess.js'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { users, lists, listShares } from '../../src/db/schema.js';
|
||||
import { getAccessibleListIds } from '../../src/lib/listAccess.js';
|
||||
|
||||
// Seed helpers — insert minimal rows and return their IDs.
|
||||
// oidc_sub uses a UUID suffix so rows never collide across test runs
|
||||
// even when the users table is not truncated between runs.
|
||||
async function seedUser(label: string): Promise<number> {
|
||||
const [result] = await db.insert(users).values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: `User ${label}`,
|
||||
color: '#000000',
|
||||
}).$returningId()
|
||||
return result.id
|
||||
const [result] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: `User ${label}`,
|
||||
color: '#000000',
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function seedList(ownerId: number, isShared = false): Promise<number> {
|
||||
const [result] = await db.insert(lists).values({
|
||||
ownerId,
|
||||
name: `List ${Math.random()}`,
|
||||
isShared,
|
||||
}).$returningId()
|
||||
return result.id
|
||||
const [result] = await db
|
||||
.insert(lists)
|
||||
.values({
|
||||
ownerId,
|
||||
name: `List ${Math.random()}`,
|
||||
isShared,
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function shareList(listId: number, userId: number): Promise<void> {
|
||||
await db.insert(listShares).values({ listId, userId })
|
||||
await db.insert(listShares).values({ listId, userId });
|
||||
}
|
||||
|
||||
describe('listAccess — getAccessibleListIds (D-04)', () => {
|
||||
it('returns ids of lists the user owns (Test 5)', async () => {
|
||||
const userId = await seedUser('owner-5')
|
||||
const listId1 = await seedList(userId)
|
||||
const listId2 = await seedList(userId)
|
||||
const userId = await seedUser('owner-5');
|
||||
const listId1 = await seedList(userId);
|
||||
const listId2 = await seedList(userId);
|
||||
|
||||
const ids = await getAccessibleListIds(userId)
|
||||
const ids = await getAccessibleListIds(userId);
|
||||
|
||||
expect(ids).toContain(listId1)
|
||||
expect(ids).toContain(listId2)
|
||||
})
|
||||
expect(ids).toContain(listId1);
|
||||
expect(ids).toContain(listId2);
|
||||
});
|
||||
|
||||
it('returns ids of lists shared to the user via list_shares (Test 6)', async () => {
|
||||
const ownerUserId = await seedUser('owner-6')
|
||||
const sharedUserId = await seedUser('shared-6')
|
||||
const sharedListId = await seedList(ownerUserId, true)
|
||||
await shareList(sharedListId, sharedUserId)
|
||||
const ownerUserId = await seedUser('owner-6');
|
||||
const sharedUserId = await seedUser('shared-6');
|
||||
const sharedListId = await seedList(ownerUserId, true);
|
||||
await shareList(sharedListId, sharedUserId);
|
||||
|
||||
const ids = await getAccessibleListIds(sharedUserId)
|
||||
const ids = await getAccessibleListIds(sharedUserId);
|
||||
|
||||
expect(ids).toContain(sharedListId)
|
||||
})
|
||||
expect(ids).toContain(sharedListId);
|
||||
});
|
||||
|
||||
it('does NOT return another user\'s private non-shared list id (Test 7 — D-04 negative)', async () => {
|
||||
const ownerUserId = await seedUser('owner-7')
|
||||
const otherUserId = await seedUser('other-7')
|
||||
const privateListId = await seedList(ownerUserId, false)
|
||||
it("does NOT return another user's private non-shared list id (Test 7 — D-04 negative)", async () => {
|
||||
const ownerUserId = await seedUser('owner-7');
|
||||
const otherUserId = await seedUser('other-7');
|
||||
const privateListId = await seedList(ownerUserId, false);
|
||||
// Deliberately NOT sharing privateListId with otherUserId
|
||||
|
||||
const ids = await getAccessibleListIds(otherUserId)
|
||||
const ids = await getAccessibleListIds(otherUserId);
|
||||
|
||||
expect(ids).not.toContain(privateListId)
|
||||
})
|
||||
expect(ids).not.toContain(privateListId);
|
||||
});
|
||||
|
||||
it('result has no duplicates when a list is both owned and shared to the owner (Test 8)', async () => {
|
||||
const userId = await seedUser('owner-8')
|
||||
const listId = await seedList(userId, true)
|
||||
const userId = await seedUser('owner-8');
|
||||
const listId = await seedList(userId, true);
|
||||
// Erroneously share the list back to the owner (degenerate case)
|
||||
await shareList(listId, userId)
|
||||
await shareList(listId, userId);
|
||||
|
||||
const ids = await getAccessibleListIds(userId)
|
||||
const ids = await getAccessibleListIds(userId);
|
||||
|
||||
const occurrences = ids.filter((id) => id === listId)
|
||||
expect(occurrences).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
const occurrences = ids.filter((id) => id === listId);
|
||||
expect(occurrences).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,70 +19,72 @@
|
||||
* pnpm --filter @familysync/api exec vitest run tests/lib/listChangeDispatcher.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { db } from '../../src/db/client.js'
|
||||
import { users, lists, listShares, pushSubscriptions } from '../../src/db/schema.js'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { users, lists, listShares, pushSubscriptions } from '../../src/db/schema.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedUser(label: string, displayName?: string): Promise<number> {
|
||||
const [result] = await db.insert(users).values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: displayName ?? `User ${label}`,
|
||||
color: '#4A90D9',
|
||||
}).$returningId()
|
||||
return result.id
|
||||
const [result] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: displayName ?? `User ${label}`,
|
||||
color: '#4A90D9',
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function seedList(ownerId: number, name: string, isShared = true): Promise<number> {
|
||||
const [result] = await db.insert(lists).values({ ownerId, name, isShared }).$returningId()
|
||||
return result.id
|
||||
const [result] = await db.insert(lists).values({ ownerId, name, isShared }).$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
async function seedShare(listId: number, userId: number): Promise<void> {
|
||||
await db.insert(listShares).values({ listId, userId })
|
||||
await db.insert(listShares).values({ listId, userId });
|
||||
}
|
||||
|
||||
async function seedSubscription(userId: number, suffix = ''): Promise<number> {
|
||||
const [result] = await db.insert(pushSubscriptions).values({
|
||||
userId,
|
||||
endpoint: `https://push.example.com/${userId}${suffix}`,
|
||||
p256dh: 'fake-p256dh-key',
|
||||
auth: 'fake-auth',
|
||||
}).$returningId()
|
||||
return result.id
|
||||
const [result] = await db
|
||||
.insert(pushSubscriptions)
|
||||
.values({
|
||||
userId,
|
||||
endpoint: `https://push.example.com/${userId}${suffix}`,
|
||||
p256dh: 'fake-p256dh-key',
|
||||
auth: 'fake-auth',
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
/** Short wait for real-timer coalescer window + async DB queries to settle. */
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms))
|
||||
return new Promise<void>((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Poll a predicate until it passes or the timeout is exceeded.
|
||||
* Replaces @testing-library/waitFor — keeps the test deps minimal.
|
||||
*/
|
||||
async function pollUntil(
|
||||
predicate: () => void,
|
||||
timeoutMs = 3000,
|
||||
intervalMs = 30,
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
let lastErr: unknown
|
||||
async function pollUntil(predicate: () => void, timeoutMs = 3000, intervalMs = 30): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastErr: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
predicate()
|
||||
return // predicate passed
|
||||
predicate();
|
||||
return; // predicate passed
|
||||
} catch (err) {
|
||||
lastErr = err
|
||||
lastErr = err;
|
||||
}
|
||||
await sleep(intervalMs)
|
||||
await sleep(intervalMs);
|
||||
}
|
||||
throw lastErr
|
||||
throw lastErr;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -91,134 +93,134 @@ async function pollUntil(
|
||||
// Use vi.doMock + vi.resetModules before each test so the mock is fresh.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const WINDOW_MS = 10
|
||||
const WINDOW_MS = 10;
|
||||
|
||||
describe('notifyListChange — access-scoped, self-suppressed, coalesced (NOTIF-02)', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
vi.resetModules();
|
||||
// Re-register the dispatchPush mock after each resetModules so fresh
|
||||
// dynamic imports of listChangeDispatcher.js get the mocked pushDispatcher.
|
||||
vi.doMock('../../src/lib/pushDispatcher.js', () => ({
|
||||
dispatchPush: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
})
|
||||
}));
|
||||
});
|
||||
|
||||
async function getDispatchPushMock() {
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
return vi.mocked(dispatchPush)
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
return vi.mocked(dispatchPush);
|
||||
}
|
||||
|
||||
it('burst of N calls coalesces into exactly one dispatchPush to the non-actor subscriber', async () => {
|
||||
const actorId = await seedUser('actor-burst', 'Alice')
|
||||
const otherId = await seedUser('other-burst', 'Bob')
|
||||
const listId = await seedList(actorId, 'Groceries')
|
||||
await seedShare(listId, otherId)
|
||||
const actorId = await seedUser('actor-burst', 'Alice');
|
||||
const otherId = await seedUser('other-burst', 'Bob');
|
||||
const listId = await seedList(actorId, 'Groceries');
|
||||
await seedShare(listId, otherId);
|
||||
|
||||
// Subscriptions: one for actor, one for other
|
||||
await seedSubscription(actorId)
|
||||
const otherSubId = await seedSubscription(otherId)
|
||||
await seedSubscription(actorId);
|
||||
const otherSubId = await seedSubscription(otherId);
|
||||
|
||||
const mockDispatch = await getDispatchPushMock()
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
|
||||
const mockDispatch = await getDispatchPushMock();
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');
|
||||
|
||||
// Three rapid calls within the coalesce window
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
|
||||
// Wait for the coalescer window to expire + DB queries to settle
|
||||
await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1))
|
||||
await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1));
|
||||
|
||||
// The called subscription must be the other user's subscription
|
||||
const [calledSub, notification] = mockDispatch.mock.calls[0]
|
||||
expect(calledSub.id).toBe(otherSubId)
|
||||
expect(calledSub.userId).toBe(otherId)
|
||||
const [calledSub, notification] = mockDispatch.mock.calls[0];
|
||||
expect(calledSub.id).toBe(otherSubId);
|
||||
expect(calledSub.userId).toBe(otherId);
|
||||
|
||||
// Notification body must contain actor name and change count
|
||||
expect(notification.body).toMatch(/Alice/)
|
||||
expect(notification.body).toMatch(/3/)
|
||||
expect(notification.body).toMatch(/Alice/);
|
||||
expect(notification.body).toMatch(/3/);
|
||||
// Notification must have a title (D-02 — no item text, just generic copy)
|
||||
expect(notification.title).toBeDefined()
|
||||
expect(typeof notification.title).toBe('string')
|
||||
})
|
||||
expect(notification.title).toBeDefined();
|
||||
expect(typeof notification.title).toBe('string');
|
||||
});
|
||||
|
||||
it('actor is never dispatched to their own subscription (D-03 self-suppression)', async () => {
|
||||
const actorId = await seedUser('actor-self-suppress', 'Charlie')
|
||||
const listId = await seedList(actorId, 'Private List')
|
||||
const actorId = await seedUser('actor-self-suppress', 'Charlie');
|
||||
const listId = await seedList(actorId, 'Private List');
|
||||
// Actor subscribes, but there are no other accessible members → audience is empty after D-03
|
||||
await seedSubscription(actorId)
|
||||
await seedSubscription(actorId);
|
||||
|
||||
const mockDispatch = await getDispatchPushMock()
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
|
||||
const mockDispatch = await getDispatchPushMock();
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');
|
||||
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
|
||||
// Wait past the window; actor's subscription must never be dispatched
|
||||
await sleep(WINDOW_MS + 200)
|
||||
await sleep(WINDOW_MS + 200);
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unrelated user with no access is never dispatched (T-05-14 access scoping)', async () => {
|
||||
const actorId = await seedUser('actor-scope', 'Dave')
|
||||
const memberId = await seedUser('member-scope', 'Eve')
|
||||
const unrelatedId = await seedUser('unrelated-scope', 'Frank')
|
||||
const actorId = await seedUser('actor-scope', 'Dave');
|
||||
const memberId = await seedUser('member-scope', 'Eve');
|
||||
const unrelatedId = await seedUser('unrelated-scope', 'Frank');
|
||||
|
||||
const listId = await seedList(actorId, 'Scoped List')
|
||||
await seedShare(listId, memberId)
|
||||
const listId = await seedList(actorId, 'Scoped List');
|
||||
await seedShare(listId, memberId);
|
||||
|
||||
await seedSubscription(actorId)
|
||||
await seedSubscription(memberId)
|
||||
await seedSubscription(actorId);
|
||||
await seedSubscription(memberId);
|
||||
// Unrelated user also has a subscription — must never be dispatched
|
||||
await seedSubscription(unrelatedId)
|
||||
await seedSubscription(unrelatedId);
|
||||
|
||||
const mockDispatch = await getDispatchPushMock()
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
|
||||
const mockDispatch = await getDispatchPushMock();
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');
|
||||
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
|
||||
// Wait for coalescer + DB queries to settle
|
||||
await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1))
|
||||
await pollUntil(() => expect(mockDispatch).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Only member (Eve) should be dispatched; unrelated (Frank) must NOT receive push
|
||||
const [calledSub] = mockDispatch.mock.calls[0]
|
||||
expect(calledSub.userId).toBe(memberId)
|
||||
expect(calledSub.userId).not.toBe(unrelatedId)
|
||||
})
|
||||
const [calledSub] = mockDispatch.mock.calls[0];
|
||||
expect(calledSub.userId).toBe(memberId);
|
||||
expect(calledSub.userId).not.toBe(unrelatedId);
|
||||
});
|
||||
|
||||
it('empty audience (no other members) → no dispatch, no crash', async () => {
|
||||
const actorId = await seedUser('actor-no-other', 'Grace')
|
||||
const listId = await seedList(actorId, 'Solo List')
|
||||
const actorId = await seedUser('actor-no-other', 'Grace');
|
||||
const listId = await seedList(actorId, 'Solo List');
|
||||
// No shares; actor-only list
|
||||
await seedSubscription(actorId)
|
||||
await seedSubscription(actorId);
|
||||
|
||||
const mockDispatch = await getDispatchPushMock()
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
|
||||
const mockDispatch = await getDispatchPushMock();
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');
|
||||
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
await sleep(WINDOW_MS + 200)
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
await sleep(WINDOW_MS + 200);
|
||||
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('empty audience (non-actor member has no subscription) → no dispatch, no crash', async () => {
|
||||
const actorId = await seedUser('actor-no-sub', 'Heidi')
|
||||
const otherId = await seedUser('other-no-sub', 'Ivan')
|
||||
const actorId = await seedUser('actor-no-sub', 'Heidi');
|
||||
const otherId = await seedUser('other-no-sub', 'Ivan');
|
||||
|
||||
const listId = await seedList(actorId, 'No Sub List')
|
||||
await seedShare(listId, otherId)
|
||||
const listId = await seedList(actorId, 'No Sub List');
|
||||
await seedShare(listId, otherId);
|
||||
|
||||
// Actor has a subscription, other does NOT
|
||||
await seedSubscription(actorId)
|
||||
await seedSubscription(actorId);
|
||||
// Deliberately no subscription for otherId
|
||||
|
||||
const mockDispatch = await getDispatchPushMock()
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js')
|
||||
const mockDispatch = await getDispatchPushMock();
|
||||
const { notifyListChange } = await import('../../src/lib/listChangeDispatcher.js');
|
||||
|
||||
notifyListChange(listId, actorId, WINDOW_MS)
|
||||
await sleep(WINDOW_MS + 200)
|
||||
notifyListChange(listId, actorId, WINDOW_MS);
|
||||
await sleep(WINDOW_MS + 200);
|
||||
|
||||
// Other has no subscription → no push dispatched
|
||||
expect(mockDispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
expect(mockDispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,84 +10,84 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/listEmitter.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import {
|
||||
publishListEvent,
|
||||
subscribeListEvents,
|
||||
type ListEvent,
|
||||
} from '../../src/lib/listEmitter.js'
|
||||
} from '../../src/lib/listEmitter.js';
|
||||
|
||||
describe('listEmitter — scoped fan-out correctness (D-04)', () => {
|
||||
const makeEvent = (listId: number): ListEvent => ({
|
||||
type: 'item:added',
|
||||
listId,
|
||||
payload: { id: 1, text: 'milk', checked: false },
|
||||
})
|
||||
});
|
||||
|
||||
it('publishListEvent delivers to a subscriber of the matching listId (Test 1)', () => {
|
||||
const received: ListEvent[] = []
|
||||
const unsub = subscribeListEvents(1, (ev) => received.push(ev))
|
||||
const received: ListEvent[] = [];
|
||||
const unsub = subscribeListEvents(1, (ev) => received.push(ev));
|
||||
|
||||
const ev = makeEvent(1)
|
||||
publishListEvent(1, ev)
|
||||
const ev = makeEvent(1);
|
||||
publishListEvent(1, ev);
|
||||
|
||||
unsub()
|
||||
expect(received).toHaveLength(1)
|
||||
expect(received[0]).toBe(ev)
|
||||
})
|
||||
unsub();
|
||||
expect(received).toHaveLength(1);
|
||||
expect(received[0]).toBe(ev);
|
||||
});
|
||||
|
||||
it('publishListEvent does NOT deliver to a subscriber of a different listId (Test 2 — D-04 negative)', () => {
|
||||
const received: ListEvent[] = []
|
||||
const unsub = subscribeListEvents(1, (ev) => received.push(ev))
|
||||
const received: ListEvent[] = [];
|
||||
const unsub = subscribeListEvents(1, (ev) => received.push(ev));
|
||||
|
||||
publishListEvent(2, makeEvent(2))
|
||||
publishListEvent(2, makeEvent(2));
|
||||
|
||||
unsub()
|
||||
expect(received).toHaveLength(0)
|
||||
})
|
||||
unsub();
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('the unsubscribe closure stops future delivery (Test 3)', () => {
|
||||
const received: ListEvent[] = []
|
||||
const unsub = subscribeListEvents(3, (ev) => received.push(ev))
|
||||
const received: ListEvent[] = [];
|
||||
const unsub = subscribeListEvents(3, (ev) => received.push(ev));
|
||||
|
||||
publishListEvent(3, makeEvent(3))
|
||||
unsub()
|
||||
publishListEvent(3, makeEvent(3))
|
||||
publishListEvent(3, makeEvent(3));
|
||||
unsub();
|
||||
publishListEvent(3, makeEvent(3));
|
||||
|
||||
expect(received).toHaveLength(1)
|
||||
})
|
||||
expect(received).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('multiple handlers on the same list channel all receive the event (Test 4)', () => {
|
||||
const calls: number[] = []
|
||||
const unsub1 = subscribeListEvents(4, () => calls.push(1))
|
||||
const unsub2 = subscribeListEvents(4, () => calls.push(2))
|
||||
const unsub3 = subscribeListEvents(4, () => calls.push(3))
|
||||
const calls: number[] = [];
|
||||
const unsub1 = subscribeListEvents(4, () => calls.push(1));
|
||||
const unsub2 = subscribeListEvents(4, () => calls.push(2));
|
||||
const unsub3 = subscribeListEvents(4, () => calls.push(3));
|
||||
|
||||
publishListEvent(4, makeEvent(4))
|
||||
publishListEvent(4, makeEvent(4));
|
||||
|
||||
unsub1()
|
||||
unsub2()
|
||||
unsub3()
|
||||
unsub1();
|
||||
unsub2();
|
||||
unsub3();
|
||||
|
||||
expect(calls).toHaveLength(3)
|
||||
expect(calls).toContain(1)
|
||||
expect(calls).toContain(2)
|
||||
expect(calls).toContain(3)
|
||||
})
|
||||
expect(calls).toHaveLength(3);
|
||||
expect(calls).toContain(1);
|
||||
expect(calls).toContain(2);
|
||||
expect(calls).toContain(3);
|
||||
});
|
||||
|
||||
it('emitter handles 100+ concurrent subscribers without MaxListeners error (D-18 scale check)', () => {
|
||||
const N = 120
|
||||
const unsubs: Array<() => void> = []
|
||||
const handler = vi.fn()
|
||||
const N = 120;
|
||||
const unsubs: Array<() => void> = [];
|
||||
const handler = vi.fn();
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
unsubs.push(subscribeListEvents(5, handler))
|
||||
unsubs.push(subscribeListEvents(5, handler));
|
||||
}
|
||||
|
||||
publishListEvent(5, makeEvent(5))
|
||||
publishListEvent(5, makeEvent(5));
|
||||
|
||||
unsubs.forEach((u) => u())
|
||||
unsubs.forEach((u) => u());
|
||||
|
||||
expect(handler).toHaveBeenCalledTimes(N)
|
||||
})
|
||||
})
|
||||
expect(handler).toHaveBeenCalledTimes(N);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,67 +10,67 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/pushCoalescer.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
describe('pushCoalescer — list-change burst coalescing (D-01)', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.resetModules()
|
||||
})
|
||||
vi.useFakeTimers();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('collapses N rapid coalesceListPush calls into a single dispatch with count=N', async () => {
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined)
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js')
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js');
|
||||
|
||||
const listId = 1
|
||||
const actorId = 10
|
||||
const N = 5
|
||||
const listId = 1;
|
||||
const actorId = 10;
|
||||
const N = 5;
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
coalesceListPush(listId, actorId, dispatch)
|
||||
coalesceListPush(listId, actorId, dispatch);
|
||||
}
|
||||
|
||||
// Advance time past the coalesce window
|
||||
await vi.runAllTimersAsync()
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
const [calledListId, calledActorId, calledCount] = dispatch.mock.calls[0]
|
||||
expect(calledListId).toBe(listId)
|
||||
expect(calledActorId).toBe(actorId)
|
||||
expect(calledCount).toBe(N)
|
||||
})
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
const [calledListId, calledActorId, calledCount] = dispatch.mock.calls[0];
|
||||
expect(calledListId).toBe(listId);
|
||||
expect(calledActorId).toBe(actorId);
|
||||
expect(calledCount).toBe(N);
|
||||
});
|
||||
|
||||
it('passes the actor userId as excludeUserId so the actor does not notify themselves (D-03)', async () => {
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined)
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js')
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js');
|
||||
|
||||
const listId = 2
|
||||
const actorId = 99
|
||||
const listId = 2;
|
||||
const actorId = 99;
|
||||
|
||||
coalesceListPush(listId, actorId, dispatch)
|
||||
await vi.runAllTimersAsync()
|
||||
coalesceListPush(listId, actorId, dispatch);
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
expect(dispatch).toHaveBeenCalledTimes(1)
|
||||
const [, calledActorId] = dispatch.mock.calls[0]
|
||||
expect(calledActorId).toBe(actorId)
|
||||
})
|
||||
expect(dispatch).toHaveBeenCalledTimes(1);
|
||||
const [, calledActorId] = dispatch.mock.calls[0];
|
||||
expect(calledActorId).toBe(actorId);
|
||||
});
|
||||
|
||||
it('fires separate dispatches for different lists independently', async () => {
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined)
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js')
|
||||
const dispatch = vi.fn().mockResolvedValue(undefined);
|
||||
const { coalesceListPush } = await import('../../src/lib/pushCoalescer.js');
|
||||
|
||||
coalesceListPush(1, 10, dispatch)
|
||||
coalesceListPush(1, 10, dispatch)
|
||||
coalesceListPush(2, 10, dispatch) // different list
|
||||
coalesceListPush(2, 10, dispatch)
|
||||
coalesceListPush(1, 10, dispatch);
|
||||
coalesceListPush(1, 10, dispatch);
|
||||
coalesceListPush(2, 10, dispatch); // different list
|
||||
coalesceListPush(2, 10, dispatch);
|
||||
|
||||
await vi.runAllTimersAsync()
|
||||
await vi.runAllTimersAsync();
|
||||
|
||||
// Two separate dispatches — one per distinct listId
|
||||
expect(dispatch).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
expect(dispatch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/pushDispatcher.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import type { PushSubscription } from '../../src/lib/pushDispatcher.js'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import type { PushSubscription } from '../../src/lib/pushDispatcher.js';
|
||||
|
||||
// Mock web-push so no real network calls occur
|
||||
vi.mock('web-push', () => ({
|
||||
@@ -20,7 +20,7 @@ vi.mock('web-push', () => ({
|
||||
sendNotification: vi.fn(),
|
||||
setVapidDetails: vi.fn(),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
// Mock the DB module so we can assert on deletes without a real database
|
||||
vi.mock('../../src/db/client.js', () => ({
|
||||
@@ -29,7 +29,7 @@ vi.mock('../../src/db/client.js', () => ({
|
||||
where: vi.fn().mockResolvedValue(undefined),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
const FAKE_SUB: PushSubscription = {
|
||||
id: 1,
|
||||
@@ -37,60 +37,64 @@ const FAKE_SUB: PushSubscription = {
|
||||
endpoint: 'https://push.example.com/sub/abc',
|
||||
p256dh: 'fake_p256dh',
|
||||
auth: 'fake_auth',
|
||||
}
|
||||
};
|
||||
|
||||
describe('pushDispatcher — 410/404 subscription pruning', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('deletes the subscription row when the push service returns 410 (Gone)', async () => {
|
||||
const webpush = (await import('web-push')).default
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const webpush = (await import('web-push')).default;
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
vi.mocked(webpush.sendNotification).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Gone'), { statusCode: 410 }),
|
||||
)
|
||||
);
|
||||
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' })
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' });
|
||||
|
||||
expect(vi.mocked(db.delete)).toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(db.delete)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('deletes the subscription row when the push service returns 404 (Not Found)', async () => {
|
||||
const webpush = (await import('web-push')).default
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const webpush = (await import('web-push')).default;
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
vi.mocked(webpush.sendNotification).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Not Found'), { statusCode: 404 }),
|
||||
)
|
||||
);
|
||||
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' })
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' });
|
||||
|
||||
expect(vi.mocked(db.delete)).toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(db.delete)).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT delete the row on 201 success', async () => {
|
||||
const webpush = (await import('web-push')).default
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
vi.mocked(webpush.sendNotification).mockResolvedValueOnce({ statusCode: 201, body: '', headers: {} })
|
||||
const webpush = (await import('web-push')).default;
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
vi.mocked(webpush.sendNotification).mockResolvedValueOnce({
|
||||
statusCode: 201,
|
||||
body: '',
|
||||
headers: {},
|
||||
});
|
||||
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' })
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' });
|
||||
|
||||
expect(vi.mocked(db.delete)).not.toHaveBeenCalled()
|
||||
})
|
||||
expect(vi.mocked(db.delete)).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does NOT delete the row on transient 5xx error', async () => {
|
||||
const webpush = (await import('web-push')).default
|
||||
const { db } = await import('../../src/db/client.js')
|
||||
const webpush = (await import('web-push')).default;
|
||||
const { db } = await import('../../src/db/client.js');
|
||||
vi.mocked(webpush.sendNotification).mockRejectedValueOnce(
|
||||
Object.assign(new Error('Service Unavailable'), { statusCode: 503 }),
|
||||
)
|
||||
);
|
||||
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js')
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' })
|
||||
const { dispatchPush } = await import('../../src/lib/pushDispatcher.js');
|
||||
await dispatchPush(FAKE_SUB, { title: 'Test', body: 'Hello' });
|
||||
|
||||
expect(vi.mocked(db.delete)).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
expect(vi.mocked(db.delete)).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,76 +5,76 @@
|
||||
* Run: pnpm --filter @familysync/api exec vitest run tests/lib/rank.test.ts
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { rankForAppend, rankBetween } from '../../src/lib/rank.js'
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { rankForAppend, rankBetween } from '../../src/lib/rank.js';
|
||||
|
||||
describe('rankForAppend', () => {
|
||||
it('returns "a0" when list is empty (no existing rank)', () => {
|
||||
const rank = rankForAppend(null)
|
||||
expect(rank).toBe('a0')
|
||||
})
|
||||
const rank = rankForAppend(null);
|
||||
expect(rank).toBe('a0');
|
||||
});
|
||||
|
||||
it('returns a rank string that sorts AFTER the given last rank', () => {
|
||||
const lastRank = 'a0'
|
||||
const newRank = rankForAppend(lastRank)
|
||||
expect(newRank > lastRank).toBe(true)
|
||||
})
|
||||
const lastRank = 'a0';
|
||||
const newRank = rankForAppend(lastRank);
|
||||
expect(newRank > lastRank).toBe(true);
|
||||
});
|
||||
|
||||
it('multiple appends produce strictly increasing ranks', () => {
|
||||
let last: string | null = null
|
||||
const ranks: string[] = []
|
||||
let last: string | null = null;
|
||||
const ranks: string[] = [];
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const r = rankForAppend(last)
|
||||
ranks.push(r)
|
||||
last = r
|
||||
const r = rankForAppend(last);
|
||||
ranks.push(r);
|
||||
last = r;
|
||||
}
|
||||
// Each successive rank must be greater than the previous
|
||||
for (let i = 1; i < ranks.length; i++) {
|
||||
expect(ranks[i] > ranks[i - 1]).toBe(true)
|
||||
expect(ranks[i] > ranks[i - 1]).toBe(true);
|
||||
}
|
||||
})
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
describe('rankBetween', () => {
|
||||
it('returns "a0" when both prev and next are null (empty list)', () => {
|
||||
const rank = rankBetween(null, null)
|
||||
expect(rank).toBe('a0')
|
||||
})
|
||||
const rank = rankBetween(null, null);
|
||||
expect(rank).toBe('a0');
|
||||
});
|
||||
|
||||
it('returns a rank that sorts between two existing ranks', () => {
|
||||
const first = rankForAppend(null) // 'a0'
|
||||
const second = rankForAppend(first) // 'a1'
|
||||
const between = rankBetween(first, second)
|
||||
expect(between > first).toBe(true)
|
||||
expect(between < second).toBe(true)
|
||||
})
|
||||
const first = rankForAppend(null); // 'a0'
|
||||
const second = rankForAppend(first); // 'a1'
|
||||
const between = rankBetween(first, second);
|
||||
expect(between > first).toBe(true);
|
||||
expect(between < second).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a rank that sorts AFTER prev when next is null', () => {
|
||||
const prev = 'a0'
|
||||
const rank = rankBetween(prev, null)
|
||||
expect(rank > prev).toBe(true)
|
||||
})
|
||||
const prev = 'a0';
|
||||
const rank = rankBetween(prev, null);
|
||||
expect(rank > prev).toBe(true);
|
||||
});
|
||||
|
||||
it('returns a rank that sorts BEFORE next when prev is null', () => {
|
||||
const next = 'a1'
|
||||
const rank = rankBetween(null, next)
|
||||
expect(rank < next).toBe(true)
|
||||
})
|
||||
const next = 'a1';
|
||||
const rank = rankBetween(null, next);
|
||||
expect(rank < next).toBe(true);
|
||||
});
|
||||
|
||||
it('produces lexicographically stable ordering across multiple insertions', () => {
|
||||
// Simulate inserting between 'a0' and 'a1' repeatedly
|
||||
const a = 'a0'
|
||||
const b = 'a1'
|
||||
const c = rankBetween(a, b)
|
||||
const d = rankBetween(a, c)
|
||||
const e = rankBetween(c, b)
|
||||
const a = 'a0';
|
||||
const b = 'a1';
|
||||
const c = rankBetween(a, b);
|
||||
const d = rankBetween(a, c);
|
||||
const e = rankBetween(c, b);
|
||||
|
||||
// All four ranks should be orderable
|
||||
expect(a < d).toBe(true)
|
||||
expect(d < c).toBe(true)
|
||||
expect(c < e).toBe(true)
|
||||
expect(e < b).toBe(true)
|
||||
})
|
||||
expect(a < d).toBe(true);
|
||||
expect(d < c).toBe(true);
|
||||
expect(c < e).toBe(true);
|
||||
expect(e < b).toBe(true);
|
||||
});
|
||||
|
||||
/**
|
||||
* Precision regression test — LIST-03, Pitfall 2.
|
||||
@@ -86,37 +86,37 @@ describe('rankBetween', () => {
|
||||
* length instead.
|
||||
*/
|
||||
it('repeated mid-point inserts produce unique strictly-increasing ranks over 100 iterations (precision — Pitfall 2)', () => {
|
||||
const ranks: string[] = [rankBetween(null, null), rankBetween(null, null)]
|
||||
const ranks: string[] = [rankBetween(null, null), rankBetween(null, null)];
|
||||
// Set up: two items with known ranks
|
||||
ranks[0] = rankBetween(null, null) // 'a0'
|
||||
ranks[1] = rankBetween(ranks[0], null) // 'a1'
|
||||
ranks[0] = rankBetween(null, null); // 'a0'
|
||||
ranks[1] = rankBetween(ranks[0], null); // 'a1'
|
||||
|
||||
// Insert 100 times between the first item and the second item
|
||||
// This is the worst-case "zipper" pattern — always inserting at the same gap
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const newRank = rankBetween(ranks[0], ranks[1])
|
||||
const newRank = rankBetween(ranks[0], ranks[1]);
|
||||
// Must be strictly between
|
||||
expect(newRank > ranks[0]).toBe(true)
|
||||
expect(newRank < ranks[1]).toBe(true)
|
||||
expect(newRank > ranks[0]).toBe(true);
|
||||
expect(newRank < ranks[1]).toBe(true);
|
||||
// Must be a non-empty string
|
||||
expect(newRank.length).toBeGreaterThan(0)
|
||||
expect(newRank.length).toBeGreaterThan(0);
|
||||
// Must be unique (not equal to any existing rank)
|
||||
expect(ranks).not.toContain(newRank)
|
||||
expect(ranks).not.toContain(newRank);
|
||||
// New item goes at index 1 (after first, before old second) — shift old items
|
||||
ranks.splice(1, 0, newRank)
|
||||
ranks.splice(1, 0, newRank);
|
||||
}
|
||||
|
||||
// Verify the final list is fully sorted ASC
|
||||
for (let i = 1; i < ranks.length; i++) {
|
||||
expect(ranks[i] > ranks[i - 1]).toBe(true)
|
||||
expect(ranks[i] > ranks[i - 1]).toBe(true);
|
||||
}
|
||||
})
|
||||
});
|
||||
|
||||
it('rank between two neighbors is strictly between them (D-13 reorder contract)', () => {
|
||||
const prev = 'a0'
|
||||
const next = 'a3'
|
||||
const middle = rankBetween(prev, next)
|
||||
expect(middle > prev).toBe(true)
|
||||
expect(middle < next).toBe(true)
|
||||
})
|
||||
})
|
||||
const prev = 'a0';
|
||||
const next = 'a3';
|
||||
const middle = rankBetween(prev, next);
|
||||
expect(middle > prev).toBe(true);
|
||||
expect(middle < next).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+788
-690
File diff suppressed because it is too large
Load Diff
@@ -14,7 +14,7 @@
|
||||
* directly and redirects to '/'.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared mock: DB — avoids real DB connections across all tests in this file.
|
||||
@@ -34,76 +34,73 @@ vi.mock('../../src/db/client.js', () => ({
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track whether oidcAuthMiddleware was registered on the app.
|
||||
// ---------------------------------------------------------------------------
|
||||
const oidcMiddlewareSpy = vi.fn(
|
||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
)
|
||||
const oidcMiddlewareSpy = vi.fn(() => async (_c: unknown, next: () => Promise<void>) => next());
|
||||
|
||||
vi.mock('@hono/oidc-auth', () => ({
|
||||
oidcAuthMiddleware: () => oidcMiddlewareSpy(),
|
||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) =>
|
||||
c.json({ ok: true }),
|
||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
||||
getAuth: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env snapshot — restored after each test.
|
||||
// ---------------------------------------------------------------------------
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
const originalBypassFlag = process.env.DEV_AUTH_BYPASS
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
const originalBypassFlag = process.env.DEV_AUTH_BYPASS;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
if (originalBypassFlag === undefined) {
|
||||
delete process.env.DEV_AUTH_BYPASS
|
||||
delete process.env.DEV_AUTH_BYPASS;
|
||||
} else {
|
||||
process.env.DEV_AUTH_BYPASS = originalBypassFlag
|
||||
process.env.DEV_AUTH_BYPASS = originalBypassFlag;
|
||||
}
|
||||
vi.resetModules()
|
||||
oidcMiddlewareSpy.mockClear()
|
||||
})
|
||||
vi.resetModules();
|
||||
oidcMiddlewareSpy.mockClear();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/login — dev-auth bypass (DEV_AUTH_BYPASS=true)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DEV_AUTH_BYPASS = 'true'
|
||||
})
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DEV_AUTH_BYPASS = 'true';
|
||||
});
|
||||
|
||||
it('returns 302 with location "/" (bypass active, guard not mounted)', async () => {
|
||||
const { app } = await import('../../src/index.js')
|
||||
const { app } = await import('../../src/index.js');
|
||||
|
||||
const res = await app.request('/api/login')
|
||||
expect(res.status).toBe(302)
|
||||
expect(res.headers.get('location')).toBe('/')
|
||||
})
|
||||
const res = await app.request('/api/login');
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get('location')).toBe('/');
|
||||
});
|
||||
|
||||
it('does not invoke oidcAuthMiddleware when bypass is active', async () => {
|
||||
const { app } = await import('../../src/index.js')
|
||||
const { app } = await import('../../src/index.js');
|
||||
|
||||
await app.request('/api/login')
|
||||
await app.request('/api/login');
|
||||
|
||||
expect(oidcMiddlewareSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
expect(oidcMiddlewareSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/login — OIDC path (no DEV_AUTH_BYPASS)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
delete process.env.DEV_AUTH_BYPASS
|
||||
})
|
||||
process.env.NODE_ENV = 'test';
|
||||
delete process.env.DEV_AUTH_BYPASS;
|
||||
});
|
||||
|
||||
it('returns 302 with location "/" when OIDC passthrough allows the request', async () => {
|
||||
const { app } = await import('../../src/index.js')
|
||||
const { app } = await import('../../src/index.js');
|
||||
|
||||
// oidcAuthMiddleware is mocked as a passthrough — request reaches the handler.
|
||||
const res = await app.request('/api/login')
|
||||
expect(res.status).toBe(302)
|
||||
expect(res.headers.get('location')).toBe('/')
|
||||
})
|
||||
})
|
||||
const res = await app.request('/api/login');
|
||||
expect(res.status).toBe(302);
|
||||
expect(res.headers.get('location')).toBe('/');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
* vitest.resetModules() ensures each test gets a fresh module registry.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared mock: DB — avoids real DB connections across all tests in this file.
|
||||
@@ -35,109 +35,106 @@ vi.mock('../../src/db/client.js', () => ({
|
||||
}),
|
||||
}),
|
||||
},
|
||||
}))
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Track whether oidcAuthMiddleware was registered on the app.
|
||||
// The spy is set up fresh per test via beforeEach/afterEach.
|
||||
// ---------------------------------------------------------------------------
|
||||
const oidcMiddlewareSpy = vi.fn(
|
||||
() => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
)
|
||||
const oidcMiddlewareSpy = vi.fn(() => async (_c: unknown, next: () => Promise<void>) => next());
|
||||
|
||||
vi.mock('@hono/oidc-auth', () => ({
|
||||
oidcAuthMiddleware: () => oidcMiddlewareSpy(),
|
||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) =>
|
||||
c.json({ ok: true }),
|
||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
||||
getAuth: vi.fn().mockResolvedValue(null),
|
||||
}))
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Env snapshot — restored after each test to avoid cross-test pollution.
|
||||
// ---------------------------------------------------------------------------
|
||||
const originalNodeEnv = process.env.NODE_ENV
|
||||
const originalBypassFlag = process.env.DEV_AUTH_BYPASS
|
||||
const originalNodeEnv = process.env.NODE_ENV;
|
||||
const originalBypassFlag = process.env.DEV_AUTH_BYPASS;
|
||||
|
||||
afterEach(() => {
|
||||
process.env.NODE_ENV = originalNodeEnv
|
||||
process.env.NODE_ENV = originalNodeEnv;
|
||||
if (originalBypassFlag === undefined) {
|
||||
delete process.env.DEV_AUTH_BYPASS
|
||||
delete process.env.DEV_AUTH_BYPASS;
|
||||
} else {
|
||||
process.env.DEV_AUTH_BYPASS = originalBypassFlag
|
||||
process.env.DEV_AUTH_BYPASS = originalBypassFlag;
|
||||
}
|
||||
vi.resetModules()
|
||||
oidcMiddlewareSpy.mockClear()
|
||||
})
|
||||
vi.resetModules();
|
||||
oidcMiddlewareSpy.mockClear();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('GET /api/me — dev-auth bypass (DEV_AUTH_BYPASS=true)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
process.env.DEV_AUTH_BYPASS = 'true'
|
||||
})
|
||||
process.env.NODE_ENV = 'test';
|
||||
process.env.DEV_AUTH_BYPASS = 'true';
|
||||
});
|
||||
|
||||
it('returns 200 with the injected dev user identity', async () => {
|
||||
// Import AFTER setting env — index.ts reads env at module load time.
|
||||
const { app } = await import('../../src/index.js')
|
||||
const { DEV_USER } = await import('../../src/auth/devBypass.js')
|
||||
const { app } = await import('../../src/index.js');
|
||||
const { DEV_USER } = await import('../../src/auth/devBypass.js');
|
||||
|
||||
const res = await app.request('/api/me')
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.request('/api/me');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json() as { user: { id: number; displayName: string; color: string } }
|
||||
expect(body).toHaveProperty('user')
|
||||
expect(body.user.id).toBe(DEV_USER.id)
|
||||
expect(body.user.displayName).toBe(DEV_USER.displayName)
|
||||
expect(body.user.color).toBe(DEV_USER.color)
|
||||
})
|
||||
const body = (await res.json()) as { user: { id: number; displayName: string; color: string } };
|
||||
expect(body).toHaveProperty('user');
|
||||
expect(body.user.id).toBe(DEV_USER.id);
|
||||
expect(body.user.displayName).toBe(DEV_USER.displayName);
|
||||
expect(body.user.color).toBe(DEV_USER.color);
|
||||
});
|
||||
|
||||
it('returns id=1 and color=#4A90D9 (first palette slot)', async () => {
|
||||
const { app } = await import('../../src/index.js')
|
||||
const { app } = await import('../../src/index.js');
|
||||
|
||||
const res = await app.request('/api/me')
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.request('/api/me');
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = await res.json() as { user: { id: number; color: string } }
|
||||
expect(body.user.id).toBe(1)
|
||||
expect(body.user.color).toBe('#4A90D9')
|
||||
})
|
||||
const body = (await res.json()) as { user: { id: number; color: string } };
|
||||
expect(body.user.id).toBe(1);
|
||||
expect(body.user.color).toBe('#4A90D9');
|
||||
});
|
||||
|
||||
it('does not invoke oidcAuthMiddleware on /api/* when bypass is active', async () => {
|
||||
const { app } = await import('../../src/index.js')
|
||||
const { app } = await import('../../src/index.js');
|
||||
|
||||
// Hit any /api/* route to trigger the middleware stack.
|
||||
await app.request('/api/me')
|
||||
await app.request('/api/me');
|
||||
|
||||
// oidcAuthMiddleware() factory must NOT have been called — index.ts skips it.
|
||||
expect(oidcMiddlewareSpy).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
expect(oidcMiddlewareSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/me — OIDC path (no DEV_AUTH_BYPASS)', () => {
|
||||
beforeEach(() => {
|
||||
process.env.NODE_ENV = 'test'
|
||||
delete process.env.DEV_AUTH_BYPASS
|
||||
})
|
||||
process.env.NODE_ENV = 'test';
|
||||
delete process.env.DEV_AUTH_BYPASS;
|
||||
});
|
||||
|
||||
it('wires oidcAuthMiddleware on /api/* when bypass is not active', async () => {
|
||||
// Import app — devBypassActive will be false, so oidcAuthMiddleware() is called
|
||||
// during app construction (index.ts registers it via app.use('/api/*', ...)).
|
||||
await import('../../src/index.js')
|
||||
await import('../../src/index.js');
|
||||
|
||||
// The spy wraps the oidcAuthMiddleware() factory call in index.ts.
|
||||
// It must have been called exactly once (one app.use registration).
|
||||
expect(oidcMiddlewareSpy).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
expect(oidcMiddlewareSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('returns 401 when no OIDC session is present (getAuth returns null)', async () => {
|
||||
const { app } = await import('../../src/index.js')
|
||||
const { app } = await import('../../src/index.js');
|
||||
|
||||
// oidcAuthMiddleware is mocked as a passthrough; getAuth is mocked to return null.
|
||||
// me.ts falls through to the getAuth path and returns 401.
|
||||
const res = await app.request('/api/me')
|
||||
expect(res.status).toBe(401)
|
||||
const body = await res.json() as { error: string }
|
||||
expect(body.error).toBe('Unauthorized')
|
||||
})
|
||||
})
|
||||
const res = await app.request('/api/me');
|
||||
expect(res.status).toBe(401);
|
||||
const body = (await res.json()) as { error: string };
|
||||
expect(body.error).toBe('Unauthorized');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,42 +13,46 @@
|
||||
* Uses the same mock boilerplate as lists.test.ts.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { db } from '../../src/db/client.js'
|
||||
import { users, pushSubscriptions } from '../../src/db/schema.js'
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { db } from '../../src/db/client.js';
|
||||
import { users, pushSubscriptions } from '../../src/db/schema.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Dev-bypass mock: inject a specific user ID as the "logged-in" user.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let currentDevUserId = 1
|
||||
let currentDevUserId = 1;
|
||||
|
||||
vi.mock('../../src/auth/devBypass.js', () => ({
|
||||
devAuthBypass: () => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
|
||||
c.set('user', { id: currentDevUserId })
|
||||
await next()
|
||||
},
|
||||
}))
|
||||
devAuthBypass:
|
||||
() => async (c: { set: (k: string, v: unknown) => void }, next: () => Promise<void>) => {
|
||||
c.set('user', { id: currentDevUserId });
|
||||
await next();
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@hono/oidc-auth', () => ({
|
||||
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
||||
getAuth: () => null,
|
||||
}))
|
||||
}));
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Seed helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function seedUser(label: string): Promise<number> {
|
||||
const [result] = await db.insert(users).values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: `User ${label}`,
|
||||
color: '#4A90D9',
|
||||
}).$returningId()
|
||||
return result.id
|
||||
const [result] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
oidcIss: 'https://auth.test',
|
||||
oidcSub: `sub-${label}-${randomUUID()}`,
|
||||
displayName: `User ${label}`,
|
||||
color: '#4A90D9',
|
||||
})
|
||||
.$returningId();
|
||||
return result.id;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -56,8 +60,8 @@ async function seedUser(label: string): Promise<number> {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
async function getApp() {
|
||||
const { app } = await import('../../src/index.js')
|
||||
return app
|
||||
const { app } = await import('../../src/index.js');
|
||||
return app;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -69,7 +73,7 @@ function jsonRequest(method: string, path: string, body?: unknown): Request {
|
||||
method,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function makeSubscriptionBody() {
|
||||
@@ -79,7 +83,7 @@ function makeSubscriptionBody() {
|
||||
p256dh: 'BNbxV8eFzxF7rPv3fakekey==',
|
||||
auth: 'fakeauthtoken==',
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -88,18 +92,18 @@ function makeSubscriptionBody() {
|
||||
|
||||
beforeEach(async () => {
|
||||
// Users are seeded fresh per test; setup.ts truncates pushSubscriptions in afterEach
|
||||
})
|
||||
});
|
||||
|
||||
describe('GET /api/push/vapid-public-key', () => {
|
||||
it('returns { publicKey } without authentication', async () => {
|
||||
process.env.VAPID_PUBLIC_KEY = 'test_public_key_value'
|
||||
const app = await getApp()
|
||||
const res = await app.fetch(new Request('http://localhost/api/push/vapid-public-key'))
|
||||
expect(res.status).toBe(200)
|
||||
const body = (await res.json()) as { publicKey: string }
|
||||
expect(typeof body.publicKey).toBe('string')
|
||||
})
|
||||
})
|
||||
process.env.VAPID_PUBLIC_KEY = 'test_public_key_value';
|
||||
const app = await getApp();
|
||||
const res = await app.fetch(new Request('http://localhost/api/push/vapid-public-key'));
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { publicKey: string };
|
||||
expect(typeof body.publicKey).toBe('string');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/push/subscription', () => {
|
||||
it('returns 401 when unauthenticated', async () => {
|
||||
@@ -107,56 +111,57 @@ describe('POST /api/push/subscription', () => {
|
||||
// and OIDC getAuth returns null — so resolveUserId returns null → 401.
|
||||
vi.doMock('../../src/auth/devBypass.js', () => ({
|
||||
devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
}))
|
||||
}));
|
||||
vi.doMock('../../src/auth/middleware.js', () => ({
|
||||
getAuth: () => null,
|
||||
oidcAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
|
||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) => c.json({ ok: true }),
|
||||
}))
|
||||
processOAuthCallback: () => async (c: { json: (v: unknown) => unknown }) =>
|
||||
c.json({ ok: true }),
|
||||
}));
|
||||
|
||||
const { app: freshApp } = await import('../../src/index.js?v=unauth')
|
||||
const res = await freshApp.fetch(jsonRequest('POST', '/api/push/subscription', makeSubscriptionBody()))
|
||||
expect(res.status).toBe(401)
|
||||
})
|
||||
const { app: freshApp } = await import('../../src/index.js?v=unauth');
|
||||
const res = await freshApp.fetch(
|
||||
jsonRequest('POST', '/api/push/subscription', makeSubscriptionBody()),
|
||||
);
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
|
||||
it('persists a push_subscriptions row scoped to the authed user', async () => {
|
||||
const userId = await seedUser('alice')
|
||||
currentDevUserId = userId
|
||||
const app = await getApp()
|
||||
const userId = await seedUser('alice');
|
||||
currentDevUserId = userId;
|
||||
const app = await getApp();
|
||||
|
||||
const body = makeSubscriptionBody()
|
||||
const res = await app.fetch(jsonRequest('POST', '/api/push/subscription', body))
|
||||
expect(res.status).toBe(201)
|
||||
const body = makeSubscriptionBody();
|
||||
const res = await app.fetch(jsonRequest('POST', '/api/push/subscription', body));
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pushSubscriptions)
|
||||
.where(
|
||||
(await import('drizzle-orm')).eq(pushSubscriptions.userId, userId),
|
||||
)
|
||||
expect(rows).toHaveLength(1)
|
||||
expect(rows[0].endpoint).toBe(body.endpoint)
|
||||
})
|
||||
})
|
||||
.where((await import('drizzle-orm')).eq(pushSubscriptions.userId, userId));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].endpoint).toBe(body.endpoint);
|
||||
});
|
||||
});
|
||||
|
||||
describe('DELETE /api/push/subscription', () => {
|
||||
it("removes the caller's subscription rows", async () => {
|
||||
const userId = await seedUser('bob')
|
||||
currentDevUserId = userId
|
||||
const app = await getApp()
|
||||
const userId = await seedUser('bob');
|
||||
currentDevUserId = userId;
|
||||
const app = await getApp();
|
||||
|
||||
// First subscribe
|
||||
await app.fetch(jsonRequest('POST', '/api/push/subscription', makeSubscriptionBody()))
|
||||
await app.fetch(jsonRequest('POST', '/api/push/subscription', makeSubscriptionBody()));
|
||||
|
||||
// Then unsubscribe
|
||||
const res = await app.fetch(jsonRequest('DELETE', '/api/push/subscription'))
|
||||
expect(res.status).toBe(200)
|
||||
const res = await app.fetch(jsonRequest('DELETE', '/api/push/subscription'));
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const { eq } = await import('drizzle-orm')
|
||||
const { eq } = await import('drizzle-orm');
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(pushSubscriptions)
|
||||
.where(eq(pushSubscriptions.userId, userId))
|
||||
expect(rows).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
.where(eq(pushSubscriptions.userId, userId));
|
||||
expect(rows).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user