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