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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user