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:
Lucas Berger
2026-06-11 20:35:18 -04:00
parent 4bc0445173
commit 982438dc10
398 changed files with 19050 additions and 16382 deletions
@@ -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');
});
});
+54 -48
View File
@@ -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);
});
});
+108 -106
View File
@@ -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();
});
});
+46 -46
View File
@@ -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);
});
});
+39 -39
View File
@@ -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);
});
});
+40 -36
View File
@@ -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();
});
});
+62 -62
View File
@@ -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);
});
});