feat(backend): emit task.created + workspace.created events; add share revocation regression test
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
This commit is contained in:
parent
c75ed3dc25
commit
1258d49488
@ -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> = {}): 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']);
|
||||
});
|
||||
});
|
||||
50
backend/src/modules/note-tasks/repository.test.ts
Normal file
50
backend/src/modules/note-tasks/repository.test.ts
Normal file
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<Note
|
||||
}
|
||||
|
||||
export async function createNoteTask(doc: NoteTaskDoc): Promise<NoteTaskDoc> {
|
||||
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<boolean> {
|
||||
|
||||
44
backend/src/modules/workspaces/repository.test.ts
Normal file
44
backend/src/modules/workspaces/repository.test.ts
Normal file
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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<Workspac
|
||||
}
|
||||
|
||||
export async function createWorkspace(doc: WorkspaceDoc): Promise<WorkspaceDoc> {
|
||||
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<boolean> {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user