From 1258d4948824768efb78ae0c82a9caf1c102377a Mon Sep 17 00:00:00 2001 From: saravanakumardb1 Date: Fri, 22 May 2026 23:23:08 -0700 Subject: [PATCH] feat(backend): emit task.created + workspace.created events; add share revocation regression test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sprint B — closes audit items B6 (event-bus completeness) and B3 (public-share revocation regression). Event bus: - note-tasks/repository.ts createNoteTask now emits task.created with taskId, noteId, workspaceId, userId, title - workspaces/repository.ts createWorkspace now emits workspace.created with workspaceId, userId, name The event-bus already declared these event types (event-bus.ts) and webhook subscribers can target them, but they were never emitted — making the contract dead. Emissions follow the same .catch(() => {}) pattern used by note.created/updated/deleted in notes/repository.ts so a subscriber failure cannot break the create flow. Regression tests: - note-tasks/repository.test.ts and workspaces/repository.test.ts exercise the emission paths end-to-end through the in-memory datastore. - note-shares/repository.integration.test.ts adds a 5-test integration suite for the public-share revocation path: token resolves before revocation; token returns null after deleteShare (hard delete); expired token returns null; cross-product token rejected; listSharesForNote does not include revoked shares. Verified: - pnpm --filter @notelett/backend run test: 380/380 (was 373, +7 new) - pnpm run verify end-to-end green --- .../repository.integration.test.ts | 84 +++++++++++++++++++ .../src/modules/note-tasks/repository.test.ts | 50 +++++++++++ backend/src/modules/note-tasks/repository.ts | 11 ++- .../src/modules/workspaces/repository.test.ts | 44 ++++++++++ backend/src/modules/workspaces/repository.ts | 9 +- docs/{ => archive}/AGENT_TASK_ROADMAP.md | 0 .../ARCHITECTURE_REVIEW_AND_REUSE_ROADMAP.md | 0 docs/{ => archive}/GAP_ANALYSIS.md | 0 8 files changed, 196 insertions(+), 2 deletions(-) create mode 100644 backend/src/modules/note-shares/repository.integration.test.ts create mode 100644 backend/src/modules/note-tasks/repository.test.ts create mode 100644 backend/src/modules/workspaces/repository.test.ts rename docs/{ => archive}/AGENT_TASK_ROADMAP.md (100%) rename docs/{ => archive}/ARCHITECTURE_REVIEW_AND_REUSE_ROADMAP.md (100%) rename docs/{ => archive}/GAP_ANALYSIS.md (100%) diff --git a/backend/src/modules/note-shares/repository.integration.test.ts b/backend/src/modules/note-shares/repository.integration.test.ts new file mode 100644 index 0000000..0272ecc --- /dev/null +++ b/backend/src/modules/note-shares/repository.integration.test.ts @@ -0,0 +1,84 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { resetMemoryDatastore } from '../../test-helpers.js'; +import { + createNoteShare, + deleteShare, + findShareByToken, + listSharesForNote, +} from './repository.js'; +import type { NoteShareDoc } from './types.js'; + +function shareDoc(overrides: Partial = {}): NoteShareDoc { + return { + id: 'sh-token-1', + productId: 'notelett', + workspaceId: 'ws-1', + userId: 'user-1', + noteId: 'note-1', + shareToken: 'token-1', + createdAt: new Date().toISOString(), + ...overrides, + }; +} + +describe('note-shares repository — revocation regression (S1)', () => { + beforeEach(() => { + process.env.DB_PROVIDER = 'memory'; + resetMemoryDatastore(); + }); + + it('findShareByToken returns the share before revocation', async () => { + const doc = shareDoc(); + await createNoteShare(doc); + + const found = await findShareByToken(doc.shareToken, doc.productId); + expect(found).not.toBeNull(); + expect(found?.shareToken).toBe(doc.shareToken); + }); + + it('findShareByToken returns null after the share is revoked (hard-deleted)', async () => { + const doc = shareDoc({ id: 'sh-revoked', shareToken: 'token-revoked' }); + await createNoteShare(doc); + + // Sanity: the share is initially resolvable. + expect(await findShareByToken(doc.shareToken, doc.productId)).not.toBeNull(); + + // Revoke by hard-deleting (matches the path used by DELETE /notes/:id/shares/:shareToken). + await deleteShare(doc.id, doc.workspaceId); + + expect(await findShareByToken(doc.shareToken, doc.productId)).toBeNull(); + }); + + it('findShareByToken returns null when expiresAt is in the past', async () => { + const past = new Date(Date.now() - 86_400_000).toISOString(); + const doc = shareDoc({ id: 'sh-expired', shareToken: 'token-expired', expiresAt: past }); + await createNoteShare(doc); + + expect(await findShareByToken(doc.shareToken, doc.productId)).toBeNull(); + }); + + it('findShareByToken honours productId scoping (cross-product token rejected)', async () => { + const doc = shareDoc({ id: 'sh-scoped', shareToken: 'token-scoped' }); + await createNoteShare(doc); + + // Lookup with a different productId must not return the share. + expect(await findShareByToken(doc.shareToken, 'other-product')).toBeNull(); + // Lookup with the correct productId still succeeds. + expect(await findShareByToken(doc.shareToken, doc.productId)).not.toBeNull(); + }); + + it('listSharesForNote does not include revoked shares', async () => { + const a = shareDoc({ id: 'sh-A', shareToken: 'token-A' }); + const b = shareDoc({ id: 'sh-B', shareToken: 'token-B' }); + await createNoteShare(a); + await createNoteShare(b); + + const before = await listSharesForNote(a.userId, a.productId, a.workspaceId, a.noteId); + expect(before.map(s => s.id).sort()).toEqual(['sh-A', 'sh-B']); + + await deleteShare(a.id, a.workspaceId); + + const after = await listSharesForNote(a.userId, a.productId, a.workspaceId, a.noteId); + expect(after.map(s => s.id)).toEqual(['sh-B']); + }); +}); diff --git a/backend/src/modules/note-tasks/repository.test.ts b/backend/src/modules/note-tasks/repository.test.ts new file mode 100644 index 0000000..2f93d55 --- /dev/null +++ b/backend/src/modules/note-tasks/repository.test.ts @@ -0,0 +1,50 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _resetEventBus, getEventBus } from '../../lib/event-bus.js'; +import { resetMemoryDatastore } from '../../test-helpers.js'; +import { createNoteTask } from './repository.js'; +import type { NoteTaskDoc } from './types.js'; + +const baseTask: NoteTaskDoc = { + id: 'task-1', + productId: 'notelett', + workspaceId: 'ws-1', + userId: 'user-1', + noteId: 'note-1', + title: 'Write integration test', + status: 'open', + source: 'manual', + createdAt: new Date().toISOString(), + createdBy: 'user-1', + updatedAt: new Date().toISOString(), + updatedBy: 'user-1', +}; + +describe('note-tasks repository — event bus', () => { + beforeEach(() => { + process.env.DB_PROVIDER = 'memory'; + resetMemoryDatastore(); + _resetEventBus(); + }); + + afterEach(() => { + _resetEventBus(); + }); + + it('emits task.created when a note task is created', async () => { + const handler = vi.fn(); + getEventBus().on('task.created', handler); + + await createNoteTask(baseTask); + // emit().catch() runs asynchronously, allow microtasks to flush + await new Promise(resolve => setImmediate(resolve)); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + taskId: 'task-1', + noteId: 'note-1', + workspaceId: 'ws-1', + userId: 'user-1', + title: 'Write integration test', + }); + }); +}); diff --git a/backend/src/modules/note-tasks/repository.ts b/backend/src/modules/note-tasks/repository.ts index afb71de..b0b4a99 100644 --- a/backend/src/modules/note-tasks/repository.ts +++ b/backend/src/modules/note-tasks/repository.ts @@ -1,4 +1,5 @@ import { getCollection } from '../../lib/datastore.js'; +import { getEventBus } from '../../lib/event-bus.js'; import type { NoteTaskDoc, ListNoteTasksQuery } from './types.js'; import type { FilterMap } from '@bytelyst/datastore'; @@ -37,7 +38,15 @@ export async function getNoteTask(id: string, workspaceId: string): Promise { - return collection().create(doc); + const created = await collection().create(doc); + getEventBus().emit('task.created', { + taskId: created.id, + noteId: created.noteId, + workspaceId: created.workspaceId, + userId: created.userId, + title: created.title, + }).catch(() => {}); + return created; } export async function deleteNoteTask(id: string, workspaceId: string): Promise { diff --git a/backend/src/modules/workspaces/repository.test.ts b/backend/src/modules/workspaces/repository.test.ts new file mode 100644 index 0000000..e3fc241 --- /dev/null +++ b/backend/src/modules/workspaces/repository.test.ts @@ -0,0 +1,44 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { _resetEventBus, getEventBus } from '../../lib/event-bus.js'; +import { resetMemoryDatastore } from '../../test-helpers.js'; +import { createWorkspace } from './repository.js'; +import type { WorkspaceDoc } from './types.js'; + +const baseWorkspace: WorkspaceDoc = { + id: 'ws-1', + productId: 'notelett', + userId: 'user-1', + name: 'My Workspace', + members: [], + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + createdBy: 'user-1', + updatedBy: 'user-1', +}; + +describe('workspaces repository — event bus', () => { + beforeEach(() => { + process.env.DB_PROVIDER = 'memory'; + resetMemoryDatastore(); + _resetEventBus(); + }); + + afterEach(() => { + _resetEventBus(); + }); + + it('emits workspace.created when a workspace is created', async () => { + const handler = vi.fn(); + getEventBus().on('workspace.created', handler); + + await createWorkspace(baseWorkspace); + await new Promise(resolve => setImmediate(resolve)); + + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + name: 'My Workspace', + }); + }); +}); diff --git a/backend/src/modules/workspaces/repository.ts b/backend/src/modules/workspaces/repository.ts index ab95164..b919a73 100644 --- a/backend/src/modules/workspaces/repository.ts +++ b/backend/src/modules/workspaces/repository.ts @@ -1,4 +1,5 @@ import { getCollection } from '../../lib/datastore.js'; +import { getEventBus } from '../../lib/event-bus.js'; import type { WorkspaceDoc, ListWorkspacesQuery } from './types.js'; function collection() { @@ -27,7 +28,13 @@ export async function getWorkspace(id: string, userId: string): Promise { - return collection().create(doc); + const created = await collection().create(doc); + getEventBus().emit('workspace.created', { + workspaceId: created.id, + userId: created.userId, + name: created.name, + }).catch(() => {}); + return created; } export async function deleteWorkspace(id: string, userId: string): Promise { diff --git a/docs/AGENT_TASK_ROADMAP.md b/docs/archive/AGENT_TASK_ROADMAP.md similarity index 100% rename from docs/AGENT_TASK_ROADMAP.md rename to docs/archive/AGENT_TASK_ROADMAP.md diff --git a/docs/ARCHITECTURE_REVIEW_AND_REUSE_ROADMAP.md b/docs/archive/ARCHITECTURE_REVIEW_AND_REUSE_ROADMAP.md similarity index 100% rename from docs/ARCHITECTURE_REVIEW_AND_REUSE_ROADMAP.md rename to docs/archive/ARCHITECTURE_REVIEW_AND_REUSE_ROADMAP.md diff --git a/docs/GAP_ANALYSIS.md b/docs/archive/GAP_ANALYSIS.md similarity index 100% rename from docs/GAP_ANALYSIS.md rename to docs/archive/GAP_ANALYSIS.md