202 lines
7.1 KiB
TypeScript
202 lines
7.1 KiB
TypeScript
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
|
|
vi.mock('../../lib/request-context.js', () => ({
|
|
getUserId: vi.fn(() => 'user_1'),
|
|
getRequestProductId: vi.fn(() => 'notelett'),
|
|
}));
|
|
vi.mock('../../lib/feature-flags.js', () => ({
|
|
isFeatureEnabled: vi.fn(() => true),
|
|
}));
|
|
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
|
|
vi.mock('../../lib/product-config.js', () => ({ PRODUCT_ID: 'notelett' }));
|
|
vi.mock('../../lib/config.js', () => ({ config: {} }));
|
|
vi.mock('../../lib/embeddings.js', () => ({
|
|
stripHtmlForEmbedding: vi.fn((s: string) => s.replace(/<[^>]*>/g, ' ').trim()),
|
|
}));
|
|
|
|
type NoteDoc = { id: string; userId?: string; productId?: string; title?: string; body?: string };
|
|
type CollabDoc = Record<string, unknown>;
|
|
|
|
const getNoteMock = vi.fn<(id: string, userId: string) => Promise<NoteDoc | null>>();
|
|
vi.mock('../notes/repository.js', () => ({
|
|
getNote: (...args: [string, string]) => getNoteMock(...args),
|
|
}));
|
|
|
|
const createCollaboratorMock = vi.fn<(doc: CollabDoc) => Promise<CollabDoc>>();
|
|
const listCollaboratorsForNoteMock = vi.fn<(noteId: string, productId: string) => Promise<CollabDoc[]>>();
|
|
const listSharedWithMeMock = vi.fn<(userId: string, productId: string) => Promise<CollabDoc[]>>();
|
|
const findCollaboratorMock = vi.fn<(noteId: string, userId: string, productId: string) => Promise<CollabDoc | null>>();
|
|
const deleteCollaboratorMock = vi.fn<(id: string, partitionKey: string) => Promise<void>>();
|
|
vi.mock('./repository.js', () => ({
|
|
createCollaborator: (...args: [CollabDoc]) => createCollaboratorMock(...args),
|
|
listCollaboratorsForNote: (...args: [string, string]) => listCollaboratorsForNoteMock(...args),
|
|
listSharedWithMe: (...args: [string, string]) => listSharedWithMeMock(...args),
|
|
findCollaborator: (...args: [string, string, string]) => findCollaboratorMock(...args),
|
|
deleteCollaborator: (...args: [string, string]) => deleteCollaboratorMock(...args),
|
|
}));
|
|
|
|
import { buildTestApp } from '../../test-helpers.js';
|
|
import { noteCollaboratorRoutes } from './routes.js';
|
|
|
|
async function buildApp() {
|
|
return buildTestApp(noteCollaboratorRoutes);
|
|
}
|
|
|
|
describe('note-collaborators routes', () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe('POST /notes/:id/share-with-user', () => {
|
|
it('shares a note with another user', async () => {
|
|
getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'user_1', productId: 'notelett' });
|
|
findCollaboratorMock.mockResolvedValueOnce(null);
|
|
|
|
const app = await buildApp();
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/notes/n1/share-with-user',
|
|
payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_2', permission: 'view' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(201);
|
|
expect(createCollaboratorMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('rejects sharing with yourself', async () => {
|
|
const app = await buildApp();
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/notes/n1/share-with-user',
|
|
payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_1', permission: 'view' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
|
|
it('rejects duplicate share', async () => {
|
|
getNoteMock.mockResolvedValueOnce({ id: 'n1', userId: 'user_1', productId: 'notelett' });
|
|
findCollaboratorMock.mockResolvedValueOnce({ id: 'existing' });
|
|
|
|
const app = await buildApp();
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/notes/n1/share-with-user',
|
|
payload: { workspaceId: 'ws-1', sharedWithUserId: 'user_2', permission: 'view' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
|
|
describe('GET /notes/:id/collaborators', () => {
|
|
it('lists collaborators', async () => {
|
|
listCollaboratorsForNoteMock.mockResolvedValueOnce([
|
|
{ id: 'c1', sharedWithUserId: 'user_2', permission: 'view' },
|
|
]);
|
|
const app = await buildApp();
|
|
const res = await app.inject({ method: 'GET', url: '/api/notes/n1/collaborators' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().items).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('GET /shared-with-me', () => {
|
|
it('lists notes shared with current user', async () => {
|
|
listSharedWithMeMock.mockResolvedValueOnce([
|
|
{ id: 'c1', noteId: 'n1', sharedWithUserId: 'user_1' },
|
|
]);
|
|
const app = await buildApp();
|
|
const res = await app.inject({ method: 'GET', url: '/api/shared-with-me' });
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
expect(res.json().items).toHaveLength(1);
|
|
});
|
|
});
|
|
|
|
describe('DELETE /notes/:noteId/collaborators/:userId', () => {
|
|
it('removes a collaborator', async () => {
|
|
findCollaboratorMock.mockResolvedValueOnce({
|
|
id: 'c1', sharedByUserId: 'user_1', sharedWithUserId: 'user_2',
|
|
});
|
|
const app = await buildApp();
|
|
const res = await app.inject({
|
|
method: 'DELETE',
|
|
url: '/api/notes/n1/collaborators/user_2',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(204);
|
|
expect(deleteCollaboratorMock).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('returns 404 for missing collaborator', async () => {
|
|
findCollaboratorMock.mockResolvedValueOnce(null);
|
|
const app = await buildApp();
|
|
const res = await app.inject({
|
|
method: 'DELETE',
|
|
url: '/api/notes/n1/collaborators/user_2',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('POST /notes/:id/export-text', () => {
|
|
it('exports note as text formats', async () => {
|
|
getNoteMock.mockResolvedValueOnce({
|
|
id: 'n1', userId: 'user_1', productId: 'notelett',
|
|
title: 'Test Note', body: '<p>Hello world</p>',
|
|
});
|
|
const app = await buildApp();
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/notes/n1/export-text',
|
|
payload: { workspaceId: 'ws-1' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.title).toBe('Test Note');
|
|
expect(body.plaintext).toBeDefined();
|
|
expect(body.markdown).toBeDefined();
|
|
expect(body.html).toBe('<p>Hello world</p>');
|
|
});
|
|
|
|
it('returns 404 for missing note', async () => {
|
|
getNoteMock.mockResolvedValueOnce(null);
|
|
const app = await buildApp();
|
|
const res = await app.inject({
|
|
method: 'POST',
|
|
url: '/api/notes/n1/export-text',
|
|
payload: { workspaceId: 'ws-1' },
|
|
});
|
|
|
|
expect(res.statusCode).toBe(404);
|
|
});
|
|
});
|
|
|
|
describe('GET /notes/:id/deep-link', () => {
|
|
it('returns deep link URLs', async () => {
|
|
getNoteMock.mockResolvedValueOnce({ id: 'n1', productId: 'notelett' });
|
|
const app = await buildApp();
|
|
const res = await app.inject({
|
|
method: 'GET',
|
|
url: '/api/notes/n1/deep-link?workspaceId=ws-1',
|
|
});
|
|
|
|
expect(res.statusCode).toBe(200);
|
|
const body = res.json();
|
|
expect(body.web).toContain('/notes/n1');
|
|
expect(body.mobile).toBe('notelett://note/n1');
|
|
});
|
|
|
|
it('requires workspaceId query param', async () => {
|
|
const app = await buildApp();
|
|
const res = await app.inject({ method: 'GET', url: '/api/notes/n1/deep-link' });
|
|
|
|
expect(res.statusCode).toBe(400);
|
|
});
|
|
});
|
|
});
|