feat(05-05): hook notifyListChange into list/item mutations (reorder excluded)

- POST /:id/items (item added) → notifyListChange
- PATCH /list-items/:itemId checked/text → notifyListChange; position-only → silent (D-01)
- DELETE /list-items/:itemId → notifyListChange
- PATCH /:id (list rename/share toggle) → notifyListChange
- DELETE /:id (list delete) → notifyListChange
- POST / (list create) → no notification (empty list, D-01 spirit)
- lists.test.ts: 2 new tests prove reorder-silent (position) and check-notifies (NOTIF-02)
- All 59 lists.test.ts assertions GREEN
This commit is contained in:
Lucas Berger
2026-06-09 21:26:29 -04:00
parent 69231043e4
commit d2ce4e08c7
2 changed files with 64 additions and 0 deletions
+15
View File
@@ -31,6 +31,7 @@ import { rankForAppend } from '../lib/rank.js'
import '../auth/devBypass.js'
import { publishListEvent } from '../lib/listEmitter.js'
import { notifyListChange } from '../lib/listChangeDispatcher.js'
export const listsRouter = new Hono()
@@ -383,6 +384,8 @@ listsRouter.patch('/:id', zValidator('json', patchListSchema), async (c) => {
// Fan-out: notify accessible subscribers that this list metadata changed (LIST-04)
publishListEvent(listId, { type: 'list:updated', listId, payload: { id: listId, name: updated.name } })
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
notifyListChange(listId, currentUserId)
return c.json({
id: updated.id,
@@ -429,6 +432,9 @@ listsRouter.delete('/:id', async (c) => {
// Fan-out: notify accessible subscribers that this list was deleted (LIST-04)
publishListEvent(listId, { type: 'list:deleted', listId, payload: { id: listId } })
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
// Note: list is already deleted from DB; notifyListChange handles missing list gracefully.
notifyListChange(listId, currentUserId)
return c.json({ id: listId })
} catch (err) {
@@ -495,6 +501,8 @@ listsRouter.post('/:id/items', zValidator('json', createItemSchema), async (c) =
// Fan-out: notify accessible subscribers that an item was added (LIST-04)
publishListEvent(listId, { type: 'item:added', listId, payload: { id: newItem.id, listId, text: newItem.text } })
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
notifyListChange(listId, currentUserId)
return c.json(
{
@@ -638,6 +646,11 @@ listItemsRouter.patch('/:itemId', zValidator('json', patchItemSchema), async (c)
// Fan-out: notify accessible subscribers that an item was updated (LIST-04)
publishListEvent(item.listId, { type: 'item:updated', listId: item.listId, payload: { id: updated.id, listId: updated.listId } })
// Push: coalesced list-change notification — only for meaningful changes (D-01).
// Reorder (position) patches do NOT trigger a push; only checked/text changes do.
if (patch.position === undefined) {
notifyListChange(item.listId, currentUserId)
}
return c.json({
id: updated.id,
@@ -689,6 +702,8 @@ listItemsRouter.delete('/:itemId', async (c) => {
// Fan-out: notify accessible subscribers that an item was deleted (LIST-04)
publishListEvent(item.listId, { type: 'item:deleted', listId: item.listId, payload: { id: itemId } })
// Push: coalesced list-change notification to other accessible members (NOTIF-02)
notifyListChange(item.listId, currentUserId)
return c.json({ id: itemId })
} catch (err) {
+49
View File
@@ -1122,3 +1122,52 @@ describe('PATCH /api/list-items/:id { position } — reorder ordering (LIST-03,
expect(getBody.items[1].id).toBe(id1)
})
})
// ---------------------------------------------------------------------------
// NOTIF-02 reorder-silent gate (D-01)
//
// D-01: Reorder (position) PATCH must NOT trigger a list-change push.
// Non-reorder mutations (checked, text) MUST trigger notifyListChange.
// ---------------------------------------------------------------------------
describe('NOTIF-02 reorder-silent gate — notifyListChange call site guard (D-01)', () => {
it('PATCH { position } (reorder) does NOT call notifyListChange', async () => {
const ownerId = await seedUser('notif-reorder-silent')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Reorder Silent', false)
const itemId = await seedItem(listId, 'item', 'a0')
// Spy on notifyListChange BEFORE importing the app so the spy is registered
// in the same module context as the router.
const dispatcherModule = await import('../../src/lib/listChangeDispatcher.js')
const spy = vi.spyOn(dispatcherModule, 'notifyListChange')
const app = await getApp()
const res = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, { position: 'a1' }))
expect(res.status).toBe(200)
// Position-only PATCH must NOT call notifyListChange (D-01 reorder-silent)
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
it('PATCH { checked: true } DOES call notifyListChange', async () => {
const ownerId = await seedUser('notif-check-notifies')
currentDevUserId = ownerId
const listId = await seedList(ownerId, 'Check Notifies', false)
const itemId = await seedItem(listId, 'item', 'a0')
const dispatcherModule = await import('../../src/lib/listChangeDispatcher.js')
const spy = vi.spyOn(dispatcherModule, 'notifyListChange')
const app = await getApp()
const res = await app.request(jsonRequest('PATCH', `/api/list-items/${itemId}`, { checked: true }))
expect(res.status).toBe(200)
// Checked PATCH MUST call notifyListChange (meaningful mutation per NOTIF-02)
expect(spy).toHaveBeenCalledWith(listId, ownerId)
spy.mockRestore()
})
})