test(smart-actions): add scheduler, webhook, copilot integration tests
This commit is contained in:
parent
0aad04f91a
commit
b8bc096adb
412
backend/src/modules/note-prompts/scheduler.test.ts
Normal file
412
backend/src/modules/note-prompts/scheduler.test.ts
Normal file
@ -0,0 +1,412 @@
|
||||
/**
|
||||
* Integration tests for prompt scheduler + webhook CRUD routes.
|
||||
* Uses buildTestApp() + Fastify .inject() for real request/response flows.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({
|
||||
extractAuth: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||
requireWriter: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||
}));
|
||||
vi.mock('../../lib/product-config.js', () => ({
|
||||
PRODUCT_ID: 'notelett',
|
||||
DISPLAY_NAME: 'NoteLett',
|
||||
productConfig: { productId: 'notelett', displayName: 'NoteLett' },
|
||||
}));
|
||||
vi.mock('../../lib/request-context.js', () => ({
|
||||
getUserId: vi.fn(() => 'user_1'),
|
||||
getRequestProductId: vi.fn(() => 'notelett'),
|
||||
}));
|
||||
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
|
||||
vi.mock('../../lib/feature-flags.js', () => ({ isFeatureEnabled: vi.fn(() => true) }));
|
||||
vi.mock('../../lib/field-encrypt.js', () => ({
|
||||
initEncryption: vi.fn(),
|
||||
getEncryptor: vi.fn(() => ({
|
||||
encrypt: vi.fn(async (v: unknown) => v),
|
||||
decrypt: vi.fn(async (v: unknown) => v),
|
||||
})),
|
||||
}));
|
||||
vi.mock('../../lib/llm.js', () => ({
|
||||
llm: vi.fn(() => ({
|
||||
isConfigured: () => true,
|
||||
chatCompletion: vi.fn(async () => ({
|
||||
content: 'Mock LLM response',
|
||||
model: 'mock-model',
|
||||
usage: { promptTokens: 10, completionTokens: 10, totalTokens: 20 },
|
||||
finishReason: 'stop',
|
||||
})),
|
||||
})),
|
||||
initLLM: vi.fn(),
|
||||
}));
|
||||
vi.mock('../../lib/embeddings.js', () => ({
|
||||
embedText: vi.fn(async () => null),
|
||||
cosineSimilarity: vi.fn(() => 0),
|
||||
stripHtmlForEmbedding: vi.fn((html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()),
|
||||
}));
|
||||
vi.mock('@bytelyst/llm', () => ({
|
||||
getLLM: vi.fn(),
|
||||
setLLM: vi.fn(),
|
||||
createLLMProvider: vi.fn(),
|
||||
buildVisionMessage: vi.fn((text: string, url: string) => ({
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text }, { type: 'image_url', image_url: { url, detail: 'auto' } }],
|
||||
})),
|
||||
hasVisionContent: vi.fn(() => false),
|
||||
isVisionMessage: vi.fn(() => false),
|
||||
getMessageText: vi.fn((msg: { content: string }) => typeof msg.content === 'string' ? msg.content : ''),
|
||||
}));
|
||||
|
||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||
import { promptSchedulerRoutes } from './scheduler.js';
|
||||
import { notePromptRoutes } from './routes.js';
|
||||
import { noteRoutes } from '../notes/routes.js';
|
||||
import { upsertBuiltinTemplate } from './repository.js';
|
||||
import { getBuiltinTemplates } from './seed.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp(async (fastify) => {
|
||||
await noteRoutes(fastify);
|
||||
await notePromptRoutes(fastify);
|
||||
await promptSchedulerRoutes(fastify);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
async function seedBuiltins() {
|
||||
for (const t of getBuiltinTemplates()) {
|
||||
await upsertBuiltinTemplate(t);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Schedule CRUD ──────────────────────────────────────────────────
|
||||
|
||||
describe('prompt-schedules CRUD', () => {
|
||||
beforeEach(() => {
|
||||
resetMemoryDatastore();
|
||||
});
|
||||
|
||||
it('POST /prompt-schedules — creates a schedule', async () => {
|
||||
await seedBuiltins();
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-schedules',
|
||||
payload: {
|
||||
workspaceId: 'ws1',
|
||||
templateId: 'builtin-summarize',
|
||||
name: 'Weekly Digest',
|
||||
cron: '0 9 * * 1',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.name).toBe('Weekly Digest');
|
||||
expect(body.cron).toBe('0 9 * * 1');
|
||||
expect(body.productId).toBe('notelett');
|
||||
expect(body.userId).toBe('user_1');
|
||||
expect(body.enabled).toBe(true);
|
||||
expect(body.lastRunAt).toBeNull();
|
||||
expect(body.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('GET /prompt-schedules — lists schedules', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-schedules',
|
||||
payload: { workspaceId: 'ws1', templateId: 't1', name: 'S1', cron: '0 9 * * *' },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-schedules',
|
||||
payload: { workspaceId: 'ws1', templateId: 't2', name: 'S2', cron: '0 10 * * *' },
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/prompt-schedules' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.items.length).toBe(2);
|
||||
expect(body.total).toBe(2);
|
||||
});
|
||||
|
||||
it('GET /prompt-schedules/:id — returns a single schedule', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-schedules',
|
||||
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Get Test', cron: '0 8 * * *' },
|
||||
});
|
||||
const id = createRes.json().id;
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/prompt-schedules/${id}` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Get Test');
|
||||
});
|
||||
|
||||
it('GET /prompt-schedules/:id — 404 for unknown id', async () => {
|
||||
const res = await app.inject({ method: 'GET', url: '/api/prompt-schedules/nonexistent' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('PATCH /prompt-schedules/:id — updates schedule', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-schedules',
|
||||
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Original', cron: '0 8 * * *' },
|
||||
});
|
||||
const id = createRes.json().id;
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/prompt-schedules/${id}`,
|
||||
payload: { name: 'Updated', enabled: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Updated');
|
||||
expect(res.json().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('DELETE /prompt-schedules/:id — deletes schedule', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-schedules',
|
||||
payload: { workspaceId: 'ws1', templateId: 't1', name: 'To Delete', cron: '0 8 * * *' },
|
||||
});
|
||||
const id = createRes.json().id;
|
||||
|
||||
const delRes = await app.inject({ method: 'DELETE', url: `/api/prompt-schedules/${id}` });
|
||||
expect(delRes.statusCode).toBe(204);
|
||||
|
||||
const getRes = await app.inject({ method: 'GET', url: `/api/prompt-schedules/${id}` });
|
||||
expect(getRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('DELETE /prompt-schedules/:id — 404 for unknown id', async () => {
|
||||
const res = await app.inject({ method: 'DELETE', url: '/api/prompt-schedules/nonexistent' });
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Webhook CRUD ──────────────────────────────────────────────────
|
||||
|
||||
describe('prompt-webhooks CRUD', () => {
|
||||
beforeEach(() => {
|
||||
resetMemoryDatastore();
|
||||
});
|
||||
|
||||
it('POST /prompt-webhooks — creates a webhook', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-webhooks',
|
||||
payload: {
|
||||
workspaceId: 'ws1',
|
||||
templateId: 't1',
|
||||
name: 'On Note Create',
|
||||
triggerEvent: 'note.created',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
expect(res.statusCode).toBe(201);
|
||||
const body = res.json();
|
||||
expect(body.name).toBe('On Note Create');
|
||||
expect(body.triggerEvent).toBe('note.created');
|
||||
expect(body.productId).toBe('notelett');
|
||||
expect(body.lastTriggeredAt).toBeNull();
|
||||
});
|
||||
|
||||
it('GET /prompt-webhooks — lists webhooks', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-webhooks',
|
||||
payload: { workspaceId: 'ws1', templateId: 't1', name: 'W1', triggerEvent: 'note.created' },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-webhooks',
|
||||
payload: { workspaceId: 'ws1', templateId: 't2', name: 'W2', triggerEvent: 'note.updated' },
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/prompt-webhooks' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().items.length).toBe(2);
|
||||
});
|
||||
|
||||
it('GET /prompt-webhooks/:id — returns single webhook', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-webhooks',
|
||||
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Fetch Test', triggerEvent: 'note.tagged' },
|
||||
});
|
||||
const id = createRes.json().id;
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: `/api/prompt-webhooks/${id}` });
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Fetch Test');
|
||||
});
|
||||
|
||||
it('PATCH /prompt-webhooks/:id — updates webhook', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-webhooks',
|
||||
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Original WH', triggerEvent: 'external' },
|
||||
});
|
||||
const id = createRes.json().id;
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'PATCH',
|
||||
url: `/api/prompt-webhooks/${id}`,
|
||||
payload: { name: 'Updated WH', enabled: false },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().name).toBe('Updated WH');
|
||||
expect(res.json().enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('DELETE /prompt-webhooks/:id — deletes webhook', async () => {
|
||||
const createRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-webhooks',
|
||||
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Del WH', triggerEvent: 'note.created' },
|
||||
});
|
||||
const id = createRes.json().id;
|
||||
|
||||
const delRes = await app.inject({ method: 'DELETE', url: `/api/prompt-webhooks/${id}` });
|
||||
expect(delRes.statusCode).toBe(204);
|
||||
|
||||
const getRes = await app.inject({ method: 'GET', url: `/api/prompt-webhooks/${id}` });
|
||||
expect(getRes.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Webhook Trigger ──────────────────────────────────────────────
|
||||
|
||||
describe('prompt-webhooks trigger', () => {
|
||||
beforeEach(() => {
|
||||
resetMemoryDatastore();
|
||||
});
|
||||
|
||||
it('POST /prompt-webhooks/:id/trigger — executes prompt on note', async () => {
|
||||
await seedBuiltins();
|
||||
|
||||
// Create a note
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'wh-note-1', workspaceId: 'ws-wh', title: 'Webhook Target', body: 'Some content to process.' },
|
||||
});
|
||||
|
||||
// Create webhook linked to builtin summarize
|
||||
const whRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-webhooks',
|
||||
payload: {
|
||||
workspaceId: 'ws-wh',
|
||||
templateId: 'builtin-summarize',
|
||||
name: 'Trigger Test',
|
||||
triggerEvent: 'external',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
const whId = whRes.json().id;
|
||||
|
||||
// Trigger it
|
||||
const triggerRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/prompt-webhooks/${whId}/trigger`,
|
||||
payload: { noteId: 'wh-note-1', workspaceId: 'ws-wh' },
|
||||
});
|
||||
expect(triggerRes.statusCode).toBe(200);
|
||||
const body = triggerRes.json();
|
||||
expect(body.triggered).toBe(true);
|
||||
expect(body.webhookId).toBe(whId);
|
||||
expect(body.result.content).toBeTruthy();
|
||||
});
|
||||
|
||||
it('POST /prompt-webhooks/:id/trigger — 404 for disabled webhook', async () => {
|
||||
await seedBuiltins();
|
||||
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'wh-note-2', workspaceId: 'ws-wh2', title: 'T', body: 'B' },
|
||||
});
|
||||
|
||||
const whRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-webhooks',
|
||||
payload: {
|
||||
workspaceId: 'ws-wh2',
|
||||
templateId: 'builtin-summarize',
|
||||
name: 'Disabled',
|
||||
triggerEvent: 'external',
|
||||
enabled: false,
|
||||
},
|
||||
});
|
||||
const whId = whRes.json().id;
|
||||
|
||||
const triggerRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/prompt-webhooks/${whId}/trigger`,
|
||||
payload: { noteId: 'wh-note-2', workspaceId: 'ws-wh2' },
|
||||
});
|
||||
expect(triggerRes.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('POST /prompt-webhooks/:id/trigger — 404 for nonexistent note', async () => {
|
||||
await seedBuiltins();
|
||||
|
||||
const whRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-webhooks',
|
||||
payload: {
|
||||
workspaceId: 'ws-wh3',
|
||||
templateId: 'builtin-summarize',
|
||||
name: 'Bad Note',
|
||||
triggerEvent: 'external',
|
||||
enabled: true,
|
||||
},
|
||||
});
|
||||
const whId = whRes.json().id;
|
||||
|
||||
const triggerRes = await app.inject({
|
||||
method: 'POST',
|
||||
url: `/api/prompt-webhooks/${whId}/trigger`,
|
||||
payload: { noteId: 'nonexistent-note', workspaceId: 'ws-wh3' },
|
||||
});
|
||||
expect(triggerRes.statusCode).toBe(404);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Scheduler diagnostics ────────────────────────────────────────
|
||||
|
||||
describe('scheduler diagnostics', () => {
|
||||
beforeEach(() => {
|
||||
resetMemoryDatastore();
|
||||
});
|
||||
|
||||
it('GET /prompt-schedules/diagnostics — returns scheduler state', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-schedules',
|
||||
payload: { workspaceId: 'ws1', templateId: 't1', name: 'Active', cron: '0 9 * * *', enabled: true },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/prompt-schedules',
|
||||
payload: { workspaceId: 'ws1', templateId: 't2', name: 'Disabled', cron: '0 10 * * *', enabled: false },
|
||||
});
|
||||
|
||||
const res = await app.inject({ method: 'GET', url: '/api/prompt-schedules/diagnostics' });
|
||||
expect(res.statusCode).toBe(200);
|
||||
const body = res.json();
|
||||
expect(body.totalSchedules).toBe(2);
|
||||
expect(body.enabled).toBe(1);
|
||||
expect(typeof body.dueNow).toBe('number');
|
||||
expect(Array.isArray(body.nextRuns)).toBe(true);
|
||||
});
|
||||
});
|
||||
237
backend/src/modules/notes/copilot.test.ts
Normal file
237
backend/src/modules/notes/copilot.test.ts
Normal file
@ -0,0 +1,237 @@
|
||||
/**
|
||||
* Integration tests for copilot transform routes (POST /notes/:id/copilot).
|
||||
* Uses buildTestApp() + Fastify .inject() for real request/response flows.
|
||||
*/
|
||||
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
|
||||
const {
|
||||
extractAuthMock,
|
||||
runCopilotTransformMock,
|
||||
suggestTitleFromBodyMock,
|
||||
} = vi.hoisted(() => ({
|
||||
extractAuthMock: vi.fn(async () => ({ sub: 'user_1', type: 'access', role: 'editor' })),
|
||||
runCopilotTransformMock: vi.fn(async (_action: string, text: string) => `transformed: ${text.slice(0, 30)}`),
|
||||
suggestTitleFromBodyMock: vi.fn(async () => 'AI Suggested Title'),
|
||||
}));
|
||||
|
||||
vi.mock('../../lib/auth.js', () => ({ extractAuth: extractAuthMock, requireWriter: extractAuthMock }));
|
||||
vi.mock('../../lib/product-config.js', () => ({
|
||||
PRODUCT_ID: 'notelett',
|
||||
DISPLAY_NAME: 'NoteLett',
|
||||
productConfig: { productId: 'notelett', displayName: 'NoteLett' },
|
||||
}));
|
||||
vi.mock('../../lib/request-context.js', () => ({
|
||||
getUserId: vi.fn(() => 'user_1'),
|
||||
getRequestProductId: vi.fn(() => 'notelett'),
|
||||
}));
|
||||
vi.mock('../../lib/telemetry.js', () => ({ trackEvent: vi.fn() }));
|
||||
vi.mock('../../lib/feature-flags.js', () => ({
|
||||
isFeatureEnabled: vi.fn(() => true),
|
||||
}));
|
||||
vi.mock('../../lib/field-encrypt.js', () => ({
|
||||
initEncryption: vi.fn(),
|
||||
getEncryptor: vi.fn(() => ({
|
||||
encrypt: vi.fn(async (v: unknown) => v),
|
||||
decrypt: vi.fn(async (v: unknown) => v),
|
||||
})),
|
||||
}));
|
||||
vi.mock('../../lib/extraction-client.js', () => ({
|
||||
extractFromText: vi.fn(async () => ({ summary: 'test summary' })),
|
||||
}));
|
||||
vi.mock('../../lib/copilot-transform.js', () => ({
|
||||
runCopilotTransform: runCopilotTransformMock,
|
||||
suggestTitleFromBody: suggestTitleFromBodyMock,
|
||||
}));
|
||||
vi.mock('../note-artifacts/repository.js', () => ({
|
||||
createNoteArtifact: vi.fn(async (doc: unknown) => doc),
|
||||
}));
|
||||
vi.mock('../note-versions/repository.js', () => ({
|
||||
appendNoteVersion: vi.fn(async () => ({})),
|
||||
listNoteVersions: vi.fn(async () => ({ items: [], total: 0 })),
|
||||
}));
|
||||
vi.mock('../note-shares/repository.js', () => ({
|
||||
createNoteShare: vi.fn(async () => ({})),
|
||||
}));
|
||||
|
||||
import { buildTestApp, resetMemoryDatastore } from '../../test-helpers.js';
|
||||
import { noteRoutes } from './routes.js';
|
||||
|
||||
let app: FastifyInstance;
|
||||
|
||||
beforeAll(async () => {
|
||||
app = await buildTestApp(async (fastify) => {
|
||||
await noteRoutes(fastify);
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await app.close();
|
||||
});
|
||||
|
||||
describe('POST /notes/:id/copilot', () => {
|
||||
beforeEach(() => {
|
||||
resetMemoryDatastore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('shorten action — transforms text and returns result', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'cop-1', workspaceId: 'ws-cop', title: 'Copilot Note', body: 'Long paragraph about AI.' },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes/cop-1/copilot',
|
||||
payload: { workspaceId: 'ws-cop', action: 'shorten', text: 'Long paragraph about AI that needs shortening.' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().text).toContain('transformed:');
|
||||
expect(runCopilotTransformMock).toHaveBeenCalledWith('shorten', 'Long paragraph about AI that needs shortening.');
|
||||
});
|
||||
|
||||
it('expand action — calls transform with expand', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'cop-2', workspaceId: 'ws-cop', title: 'Short Note', body: 'Brief.' },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes/cop-2/copilot',
|
||||
payload: { workspaceId: 'ws-cop', action: 'expand', text: 'Brief.' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(runCopilotTransformMock).toHaveBeenCalledWith('expand', 'Brief.');
|
||||
});
|
||||
|
||||
it('bulletize action — calls transform with bulletize', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'cop-3', workspaceId: 'ws-cop', title: 'List Note', body: 'Items to list.' },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes/cop-3/copilot',
|
||||
payload: { workspaceId: 'ws-cop', action: 'bulletize', text: 'First point. Second point. Third point.' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(runCopilotTransformMock).toHaveBeenCalledWith('bulletize', 'First point. Second point. Third point.');
|
||||
});
|
||||
|
||||
it('grammar action — calls transform with grammar', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'cop-4', workspaceId: 'ws-cop', title: 'Grammar Note', body: 'Typos here.' },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes/cop-4/copilot',
|
||||
payload: { workspaceId: 'ws-cop', action: 'grammar', text: 'Ths haz typos in it.' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(runCopilotTransformMock).toHaveBeenCalledWith('grammar', 'Ths haz typos in it.');
|
||||
});
|
||||
|
||||
it('change-tone with tone parameter — appends tone to text', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'cop-5', workspaceId: 'ws-cop', title: 'Tone Note', body: 'Casual text.' },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes/cop-5/copilot',
|
||||
payload: { workspaceId: 'ws-cop', action: 'change-tone', text: 'Hey whats up', tone: 'formal' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(runCopilotTransformMock).toHaveBeenCalledWith('change-tone', 'Hey whats up\n\nTone: formal');
|
||||
});
|
||||
|
||||
it('404 for nonexistent note', async () => {
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes/nonexistent/copilot',
|
||||
payload: { workspaceId: 'ws-cop', action: 'shorten', text: 'text' },
|
||||
});
|
||||
expect(res.statusCode).toBe(404);
|
||||
});
|
||||
|
||||
it('400 for invalid action', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'cop-6', workspaceId: 'ws-cop', title: 'T', body: 'B' },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes/cop-6/copilot',
|
||||
payload: { workspaceId: 'ws-cop', action: 'invalid-action', text: 'text' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
|
||||
it('400 for missing text', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'cop-7', workspaceId: 'ws-cop', title: 'T', body: 'B' },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes/cop-7/copilot',
|
||||
payload: { workspaceId: 'ws-cop', action: 'shorten' },
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /notes/:id/suggest-title', () => {
|
||||
beforeEach(() => {
|
||||
resetMemoryDatastore();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns suggested title from body', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'st-1', workspaceId: 'ws-st', title: 'Original', body: 'A long body about machine learning.' },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes/st-1/suggest-title',
|
||||
payload: { workspaceId: 'ws-st' },
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.json().title).toBe('AI Suggested Title');
|
||||
expect(suggestTitleFromBodyMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('400 without workspaceId', async () => {
|
||||
await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes',
|
||||
payload: { id: 'st-2', workspaceId: 'ws-st', title: 'T', body: 'B' },
|
||||
});
|
||||
|
||||
const res = await app.inject({
|
||||
method: 'POST',
|
||||
url: '/api/notes/st-2/suggest-title',
|
||||
payload: {},
|
||||
});
|
||||
expect(res.statusCode).toBe(400);
|
||||
});
|
||||
});
|
||||
133
web/e2e/smart-actions.spec.ts
Normal file
133
web/e2e/smart-actions.spec.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
|
||||
test.describe("Smart Actions", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
// Mock all backend API calls
|
||||
await page.route("**/api/note-prompts**", (route) => {
|
||||
if (route.request().method() === "GET") {
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
id: "builtin-summarize",
|
||||
slug: "summarize",
|
||||
name: "Summarize",
|
||||
category: "transform",
|
||||
isBuiltin: true,
|
||||
inputType: "text",
|
||||
outputType: "new_note",
|
||||
},
|
||||
{
|
||||
id: "custom-1",
|
||||
slug: "my-custom",
|
||||
name: "My Custom Template",
|
||||
category: "generate",
|
||||
isBuiltin: false,
|
||||
inputType: "text",
|
||||
outputType: "replace",
|
||||
},
|
||||
],
|
||||
total: 2,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (route.request().method() === "POST") {
|
||||
return route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
id: "new-template-1",
|
||||
slug: "new-template",
|
||||
name: "New Template",
|
||||
category: "transform",
|
||||
isBuiltin: false,
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (route.request().method() === "DELETE") {
|
||||
return route.fulfill({ status: 204 });
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: "application/json", body: "{}" });
|
||||
});
|
||||
|
||||
await page.route("**/api/notes**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], total: 0 }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route("**/api/workspaces**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], total: 0 }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route("**/api/prompt-schedules**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], total: 0 }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route("**/api/prompt-webhooks**", (route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({ items: [], total: 0 }),
|
||||
})
|
||||
);
|
||||
|
||||
await page.route("**/api/**", (route) =>
|
||||
route.fulfill({ status: 200, contentType: "application/json", body: "{}" })
|
||||
);
|
||||
});
|
||||
|
||||
test("dashboard page loads with smart actions API mocked", async ({ page }) => {
|
||||
await page.goto("/dashboard");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
});
|
||||
|
||||
test("note detail page loads without JS errors", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
|
||||
await page.goto("/notes/test-note-1");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
|
||||
const realErrors = errors.filter(
|
||||
(e) =>
|
||||
!e.includes("fetch") &&
|
||||
!e.includes("Failed") &&
|
||||
!e.includes("Unexpected") &&
|
||||
!e.includes("hydration")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("settings page loads without JS errors", async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (err) => errors.push(err.message));
|
||||
|
||||
await page.goto("/settings");
|
||||
await page.waitForLoadState("domcontentloaded");
|
||||
await expect(page.locator("body")).toBeVisible();
|
||||
|
||||
const realErrors = errors.filter(
|
||||
(e) =>
|
||||
!e.includes("fetch") &&
|
||||
!e.includes("Failed") &&
|
||||
!e.includes("Unexpected") &&
|
||||
!e.includes("hydration")
|
||||
);
|
||||
expect(realErrors).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user