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:
saravanakumardb1 2026-05-22 23:23:08 -07:00
parent c75ed3dc25
commit 1258d49488
8 changed files with 196 additions and 2 deletions

View File

@ -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']);
});
});

View 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',
});
});
});

View File

@ -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> {

View 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',
});
});
});

View File

@ -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> {