mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
chore: move collab backend to proper DI
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
* see tools-todos.test.ts; trek://trips/{tripId}/packing and .../packing/bags
|
||||
* moved to PackingMcp — see tools-packing.test.ts;
|
||||
* trek://trips/{tripId}/days/{dayId}/notes moved to DayNotesMcp — see
|
||||
* tools-notes.test.ts).
|
||||
* tools-notes.test.ts; trek://trips/{tripId}/collab-notes moved to CollabMcp —
|
||||
* see tools-notes.test.ts).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
|
||||
@@ -333,33 +334,9 @@ describe('Resource: trek://trips/{tripId}/members', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Resource: trek://trips/{tripId}/collab-notes', () => {
|
||||
it('returns collab notes with username', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createCollabNote(testDb, trip.id, user.id, { title: 'Ideas' });
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` });
|
||||
const notes = parseResourceResult(result) as any[];
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].title).toBe('Ideas');
|
||||
expect(notes[0].username).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for unauthorized trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
|
||||
await withHarness(user.id, async (harness) => {
|
||||
const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
// trek://trips/{tripId}/collab-notes moved to tools-notes.test.ts: it now
|
||||
// registers via the nest-mcp registry inside registerTools (CollabMcp
|
||||
// @ResourceTemplate), which this file's withTools: false harness never attaches.
|
||||
|
||||
// trek://categories moved to tools-categories.test.ts: it now registers via
|
||||
// the nest-mcp registry inside registerTools (CategoriesMcp @Resource), which
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
* Unit tests for MCP collab polls and chat tools (collab addon-gated):
|
||||
* list_collab_polls, create_collab_poll, vote_collab_poll, close_collab_poll,
|
||||
* delete_collab_poll, list_collab_messages, send_collab_message,
|
||||
* delete_collab_message, react_collab_message.
|
||||
* delete_collab_message, react_collab_message (CollabMcp, DI-discovered —
|
||||
* attached via the nest-mcp registry inside registerTools).
|
||||
* Resources: trek://trips/{tripId}/collab/polls, trek://trips/{tripId}/collab/messages.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
* Unit tests for MCP note tools: create_day_note, update_day_note, delete_day_note
|
||||
* (DayNotesMcp, DI-discovered — attached via the nest-mcp registry inside
|
||||
* registerTools, so every harness here keeps withTools on) and
|
||||
* create_collab_note, update_collab_note, delete_collab_note (still on the
|
||||
* legacy registrar).
|
||||
* create_collab_note, update_collab_note, delete_collab_note (CollabMcp,
|
||||
* DI-discovered via the same registry).
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
|
||||
|
||||
@@ -510,3 +510,35 @@ describe('Tool: delete_collab_note', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Moved from resources.test.ts with the collab migration: the resource now
|
||||
// registers via the nest-mcp registry inside registerTools (CollabMcp
|
||||
// @ResourceTemplate), which resources.test.ts's withTools: false harness
|
||||
// never attaches.
|
||||
describe('Resource: trek://trips/{tripId}/collab-notes', () => {
|
||||
it('returns collab notes with username', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
createCollabNote(testDb, trip.id, user.id, { title: 'Ideas' });
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` });
|
||||
const notes = parseResourceResult(result) as any[];
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].title).toBe('Ideas');
|
||||
expect(notes[0].username).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns access denied for unauthorized trip', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { user: other } = createUser(testDb);
|
||||
const trip = createTrip(testDb, other.id);
|
||||
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` });
|
||||
const data = parseResourceResult(result) as any;
|
||||
expect(data.error).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+98
-69
@@ -1,7 +1,10 @@
|
||||
/**
|
||||
* Unit tests for collabService — COLLAB-SVC-001 to COLLAB-SVC-030.
|
||||
* Covers votePoll edge cases, listMessages pagination, deleteMessage ownership,
|
||||
* updateNote partial fields, fetchLinkPreview, avatarUrl, createMessage reply validation.
|
||||
* Unit tests for the DI-native CollabService — COLLAB-SVC-001 to COLLAB-SVC-033
|
||||
* (001–030 moved 1:1 from the legacy tests/unit/services/collabService.test.ts;
|
||||
* 031–033 pin the collab.bridge delegation). Covers votePoll edge cases,
|
||||
* listMessages pagination, deleteMessage ownership, updateNote partial fields,
|
||||
* linkPreview, avatarUrl, createMessage reply validation. Uses a real in-memory
|
||||
* SQLite DB so SQL logic is exercised faithfully.
|
||||
*/
|
||||
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
|
||||
|
||||
@@ -36,8 +39,9 @@ vi.mock('../../../src/config', () => ({
|
||||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||||
updateJwtSecret: () => {},
|
||||
}));
|
||||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||||
|
||||
// Stub checkSsrf so fetchLinkPreview tests can control SSRF behaviour
|
||||
// Stub checkSsrf so linkPreview tests can control SSRF behaviour
|
||||
const { mockCheckSsrf, mockCreatePinnedDispatcher } = vi.hoisted(() => ({
|
||||
mockCheckSsrf: vi.fn(async () => ({ allowed: true, resolvedIp: '93.184.216.34' })),
|
||||
mockCreatePinnedDispatcher: vi.fn(() => ({})),
|
||||
@@ -51,18 +55,12 @@ import { createTables } from '../../../src/db/schema';
|
||||
import { runMigrations } from '../../../src/db/migrations';
|
||||
import { resetTestDb } from '../../helpers/test-db';
|
||||
import { createUser, createTrip } from '../../helpers/factories';
|
||||
import {
|
||||
avatarUrl,
|
||||
votePoll,
|
||||
listMessages,
|
||||
createMessage,
|
||||
deleteMessage,
|
||||
updateNote,
|
||||
createNote,
|
||||
createPoll,
|
||||
closePoll,
|
||||
fetchLinkPreview,
|
||||
} from '../../../src/services/collabService';
|
||||
import { avatarUrl } from '../../../src/services/avatarUrl';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { CollabService } from '../../../src/nest/collab/collab.service';
|
||||
import { listNotes as bridgeListNotes, listPolls as bridgeListPolls, countMessages as bridgeCountMessages } from '../../../src/nest/collab/collab.bridge';
|
||||
|
||||
const svc = new CollabService(new DatabaseService(testDb));
|
||||
|
||||
beforeAll(() => {
|
||||
createTables(testDb);
|
||||
@@ -114,40 +112,40 @@ describe('avatarUrl', () => {
|
||||
describe('votePoll', () => {
|
||||
it('COLLAB-SVC-004: returns error "closed" when poll is closed', () => {
|
||||
const { user1, trip } = setup();
|
||||
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||||
closePoll(trip.id, poll!.id);
|
||||
const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||||
svc.closePoll(trip.id, poll!.id);
|
||||
|
||||
const result = votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
const result = svc.votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
expect(result.error).toBe('closed');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-005: returns error "invalid_index" for negative index', () => {
|
||||
const { user1, trip } = setup();
|
||||
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||||
const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||||
|
||||
const result = votePoll(trip.id, poll!.id, user1.id, -1);
|
||||
const result = svc.votePoll(trip.id, poll!.id, user1.id, -1);
|
||||
expect(result.error).toBe('invalid_index');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-006: returns error "invalid_index" for out-of-range index', () => {
|
||||
const { user1, trip } = setup();
|
||||
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||||
const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||||
|
||||
const result = votePoll(trip.id, poll!.id, user1.id, 5);
|
||||
const result = svc.votePoll(trip.id, poll!.id, user1.id, 5);
|
||||
expect(result.error).toBe('invalid_index');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-007: returns error "not_found" for nonexistent poll', () => {
|
||||
const { user1, trip } = setup();
|
||||
const result = votePoll(trip.id, 9999, user1.id, 0);
|
||||
const result = svc.votePoll(trip.id, 9999, user1.id, 0);
|
||||
expect(result.error).toBe('not_found');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-008: successfully votes and returns poll with voters', () => {
|
||||
const { user1, trip } = setup();
|
||||
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] });
|
||||
const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] });
|
||||
|
||||
const result = votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
const result = svc.votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
expect(result.error).toBeUndefined();
|
||||
expect(result.poll).toBeDefined();
|
||||
expect(result.poll!.options[0].voters).toHaveLength(1);
|
||||
@@ -155,10 +153,10 @@ describe('votePoll', () => {
|
||||
|
||||
it('COLLAB-SVC-009: toggles vote off when voted again on same option', () => {
|
||||
const { user1, trip } = setup();
|
||||
const poll = createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] });
|
||||
const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] });
|
||||
|
||||
votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
const result = votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
svc.votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
const result = svc.votePoll(trip.id, poll!.id, user1.id, 0);
|
||||
expect(result.poll!.options[0].voters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -168,21 +166,21 @@ describe('votePoll', () => {
|
||||
describe('listMessages', () => {
|
||||
it('COLLAB-SVC-010: returns all messages when no before cursor', () => {
|
||||
const { user1, trip } = setup();
|
||||
createMessage(trip.id, user1.id, 'Hello');
|
||||
createMessage(trip.id, user1.id, 'World');
|
||||
svc.createMessage(trip.id, user1.id, 'Hello');
|
||||
svc.createMessage(trip.id, user1.id, 'World');
|
||||
|
||||
const msgs = listMessages(trip.id);
|
||||
const msgs = svc.listMessages(trip.id);
|
||||
expect(msgs).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-011: paginates using before cursor (returns messages with id < before)', () => {
|
||||
const { user1, trip } = setup();
|
||||
const r1 = createMessage(trip.id, user1.id, 'First');
|
||||
const r2 = createMessage(trip.id, user1.id, 'Second');
|
||||
const r3 = createMessage(trip.id, user1.id, 'Third');
|
||||
svc.createMessage(trip.id, user1.id, 'First');
|
||||
svc.createMessage(trip.id, user1.id, 'Second');
|
||||
const r3 = svc.createMessage(trip.id, user1.id, 'Third');
|
||||
|
||||
const id3 = r3.message!.id;
|
||||
const msgs = listMessages(trip.id, id3);
|
||||
const msgs = svc.listMessages(trip.id, id3);
|
||||
expect(msgs.length).toBe(2);
|
||||
const texts = msgs.map(m => m.text);
|
||||
expect(texts).toContain('First');
|
||||
@@ -192,22 +190,22 @@ describe('listMessages', () => {
|
||||
|
||||
it('COLLAB-SVC-012: returns messages in ascending order (reversed after DESC query)', () => {
|
||||
const { user1, trip } = setup();
|
||||
createMessage(trip.id, user1.id, 'A');
|
||||
createMessage(trip.id, user1.id, 'B');
|
||||
createMessage(trip.id, user1.id, 'C');
|
||||
svc.createMessage(trip.id, user1.id, 'A');
|
||||
svc.createMessage(trip.id, user1.id, 'B');
|
||||
svc.createMessage(trip.id, user1.id, 'C');
|
||||
|
||||
const msgs = listMessages(trip.id);
|
||||
const msgs = svc.listMessages(trip.id);
|
||||
expect(msgs[0].text).toBe('A');
|
||||
expect(msgs[2].text).toBe('C');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-013: includes reactions grouped by emoji', () => {
|
||||
const { user1, trip } = setup();
|
||||
const r = createMessage(trip.id, user1.id, 'React me');
|
||||
const r = svc.createMessage(trip.id, user1.id, 'React me');
|
||||
const msgId = r.message!.id;
|
||||
testDb.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)').run(msgId, user1.id, '👍');
|
||||
|
||||
const msgs = listMessages(trip.id);
|
||||
const msgs = svc.listMessages(trip.id);
|
||||
expect(msgs[0].reactions).toBeDefined();
|
||||
expect(msgs[0].reactions).toHaveLength(1);
|
||||
expect(msgs[0].reactions[0].emoji).toBe('👍');
|
||||
@@ -219,14 +217,14 @@ describe('listMessages', () => {
|
||||
describe('createMessage', () => {
|
||||
it('COLLAB-SVC-014: returns error when replyTo message does not exist', () => {
|
||||
const { user1, trip } = setup();
|
||||
const result = createMessage(trip.id, user1.id, 'Reply to nothing', 9999);
|
||||
const result = svc.createMessage(trip.id, user1.id, 'Reply to nothing', 9999);
|
||||
expect(result.error).toBe('reply_not_found');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-015: creates message with valid replyTo', () => {
|
||||
const { user1, trip } = setup();
|
||||
const r1 = createMessage(trip.id, user1.id, 'Original');
|
||||
const r2 = createMessage(trip.id, user1.id, 'Reply', r1.message!.id);
|
||||
const r1 = svc.createMessage(trip.id, user1.id, 'Original');
|
||||
const r2 = svc.createMessage(trip.id, user1.id, 'Reply', r1.message!.id);
|
||||
expect(r2.error).toBeUndefined();
|
||||
expect(r2.message!.reply_to).toBe(r1.message!.id);
|
||||
});
|
||||
@@ -237,23 +235,23 @@ describe('createMessage', () => {
|
||||
describe('deleteMessage', () => {
|
||||
it('COLLAB-SVC-016: returns error "not_owner" when user does not own message', () => {
|
||||
const { user1, user2, trip } = setup();
|
||||
const r = createMessage(trip.id, user1.id, 'My message');
|
||||
const r = svc.createMessage(trip.id, user1.id, 'My message');
|
||||
|
||||
const result = deleteMessage(trip.id, r.message!.id, user2.id);
|
||||
const result = svc.deleteMessage(trip.id, r.message!.id, user2.id);
|
||||
expect(result.error).toBe('not_owner');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-017: returns error "not_found" for nonexistent message', () => {
|
||||
const { user1, trip } = setup();
|
||||
const result = deleteMessage(trip.id, 9999, user1.id);
|
||||
const result = svc.deleteMessage(trip.id, 9999, user1.id);
|
||||
expect(result.error).toBe('not_found');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-018: marks message as deleted when owner deletes it', () => {
|
||||
const { user1, trip } = setup();
|
||||
const r = createMessage(trip.id, user1.id, 'Delete me');
|
||||
const r = svc.createMessage(trip.id, user1.id, 'Delete me');
|
||||
|
||||
const result = deleteMessage(trip.id, r.message!.id, user1.id);
|
||||
const result = svc.deleteMessage(trip.id, r.message!.id, user1.id);
|
||||
expect(result.error).toBeUndefined();
|
||||
|
||||
const row = testDb.prepare('SELECT deleted FROM collab_messages WHERE id = ?').get(r.message!.id) as any;
|
||||
@@ -266,9 +264,9 @@ describe('deleteMessage', () => {
|
||||
describe('updateNote', () => {
|
||||
it('COLLAB-SVC-019: updates only title when other fields are undefined', () => {
|
||||
const { user1, trip } = setup();
|
||||
const note = createNote(trip.id, user1.id, { title: 'Original', content: 'Some content', website: 'https://example.com' });
|
||||
const note = svc.createNote(trip.id, user1.id, { title: 'Original', content: 'Some content', website: 'https://example.com' });
|
||||
|
||||
updateNote(trip.id, note.id, { title: 'Updated' });
|
||||
svc.updateNote(trip.id, note.id, { title: 'Updated' });
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(updated.title).toBe('Updated');
|
||||
@@ -278,9 +276,9 @@ describe('updateNote', () => {
|
||||
|
||||
it('COLLAB-SVC-020: clears content when content is explicitly set to empty string', () => {
|
||||
const { user1, trip } = setup();
|
||||
const note = createNote(trip.id, user1.id, { title: 'T', content: 'Old content' });
|
||||
const note = svc.createNote(trip.id, user1.id, { title: 'T', content: 'Old content' });
|
||||
|
||||
updateNote(trip.id, note.id, { content: '' });
|
||||
svc.updateNote(trip.id, note.id, { content: '' });
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(updated.content).toBe('');
|
||||
@@ -288,9 +286,9 @@ describe('updateNote', () => {
|
||||
|
||||
it('COLLAB-SVC-021: updates website when website is defined', () => {
|
||||
const { user1, trip } = setup();
|
||||
const note = createNote(trip.id, user1.id, { title: 'T' });
|
||||
const note = svc.createNote(trip.id, user1.id, { title: 'T' });
|
||||
|
||||
updateNote(trip.id, note.id, { website: 'https://new.example.com' });
|
||||
svc.updateNote(trip.id, note.id, { website: 'https://new.example.com' });
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(updated.website).toBe('https://new.example.com');
|
||||
@@ -298,9 +296,9 @@ describe('updateNote', () => {
|
||||
|
||||
it('COLLAB-SVC-022: clears website when website is explicitly set to empty string', () => {
|
||||
const { user1, trip } = setup();
|
||||
const note = createNote(trip.id, user1.id, { title: 'T', website: 'https://old.com' });
|
||||
const note = svc.createNote(trip.id, user1.id, { title: 'T', website: 'https://old.com' });
|
||||
|
||||
updateNote(trip.id, note.id, { website: '' });
|
||||
svc.updateNote(trip.id, note.id, { website: '' });
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(updated.website).toBe('');
|
||||
@@ -308,24 +306,24 @@ describe('updateNote', () => {
|
||||
|
||||
it('COLLAB-SVC-023: returns null when note does not exist', () => {
|
||||
const { trip } = setup();
|
||||
const result = updateNote(trip.id, 9999, { title: 'Ghost' });
|
||||
const result = svc.updateNote(trip.id, 9999, { title: 'Ghost' });
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-024: updates pinned flag', () => {
|
||||
const { user1, trip } = setup();
|
||||
const note = createNote(trip.id, user1.id, { title: 'T', pinned: false });
|
||||
const note = svc.createNote(trip.id, user1.id, { title: 'T', pinned: false });
|
||||
|
||||
updateNote(trip.id, note.id, { pinned: true });
|
||||
svc.updateNote(trip.id, note.id, { pinned: true });
|
||||
|
||||
const updated = testDb.prepare('SELECT * FROM collab_notes WHERE id = ?').get(note.id) as any;
|
||||
expect(updated.pinned).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
// ── fetchLinkPreview ──────────────────────────────────────────────────────────
|
||||
// ── linkPreview ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('fetchLinkPreview', () => {
|
||||
describe('linkPreview', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -345,7 +343,7 @@ describe('fetchLinkPreview', () => {
|
||||
`,
|
||||
}));
|
||||
|
||||
const result = await fetchLinkPreview('https://example.com/page');
|
||||
const result = await svc.linkPreview('https://example.com/page');
|
||||
expect(result.title).toBe('Test Title');
|
||||
expect(result.description).toBe('Test Description');
|
||||
expect(result.image).toBe('https://example.com/image.jpg');
|
||||
@@ -358,7 +356,7 @@ describe('fetchLinkPreview', () => {
|
||||
text: async () => `<html><head><title>Page Title</title></head></html>`,
|
||||
}));
|
||||
|
||||
const result = await fetchLinkPreview('https://example.com/');
|
||||
const result = await svc.linkPreview('https://example.com/');
|
||||
expect(result.title).toBe('Page Title');
|
||||
});
|
||||
|
||||
@@ -368,7 +366,7 @@ describe('fetchLinkPreview', () => {
|
||||
text: async () => '',
|
||||
}));
|
||||
|
||||
const result = await fetchLinkPreview('https://example.com/bad');
|
||||
const result = await svc.linkPreview('https://example.com/bad');
|
||||
expect(result.title).toBeNull();
|
||||
expect(result.description).toBeNull();
|
||||
expect(result.url).toBe('https://example.com/bad');
|
||||
@@ -377,14 +375,14 @@ describe('fetchLinkPreview', () => {
|
||||
it('COLLAB-SVC-028: returns fallback when SSRF check blocks the URL', async () => {
|
||||
mockCheckSsrf.mockResolvedValue({ allowed: false, error: 'SSRF blocked' });
|
||||
|
||||
const result = await fetchLinkPreview('https://169.254.169.254/');
|
||||
const result = await svc.linkPreview('https://169.254.169.254/');
|
||||
expect(result.title).toBeNull();
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-029: returns fallback when fetch throws (network error)', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
|
||||
|
||||
const result = await fetchLinkPreview('https://example.com/net-error');
|
||||
const result = await svc.linkPreview('https://example.com/net-error');
|
||||
expect(result.title).toBeNull();
|
||||
expect(result.url).toBe('https://example.com/net-error');
|
||||
});
|
||||
@@ -399,7 +397,38 @@ describe('fetchLinkPreview', () => {
|
||||
`,
|
||||
}));
|
||||
|
||||
const result = await fetchLinkPreview('https://example.com/meta');
|
||||
const result = await svc.linkPreview('https://example.com/meta');
|
||||
expect(result.description).toBe('Meta description here');
|
||||
});
|
||||
});
|
||||
|
||||
// ── collab.bridge delegation (out-of-container consumers) ─────────────────────
|
||||
|
||||
describe('collab.bridge', () => {
|
||||
it('COLLAB-SVC-031: listNotes delegates to CollabService over the shared db', () => {
|
||||
const { user1, trip } = setup();
|
||||
svc.createNote(trip.id, user1.id, { title: 'Bridged note' });
|
||||
|
||||
const notes = bridgeListNotes(trip.id);
|
||||
expect(notes).toHaveLength(1);
|
||||
expect(notes[0].title).toBe('Bridged note');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-032: listPolls delegates to CollabService over the shared db', () => {
|
||||
const { user1, trip } = setup();
|
||||
svc.createPoll(trip.id, user1.id, { question: 'Bridged?', options: ['A', 'B'] });
|
||||
|
||||
const polls = bridgeListPolls(trip.id);
|
||||
expect(polls).toHaveLength(1);
|
||||
expect(polls[0]!.question).toBe('Bridged?');
|
||||
});
|
||||
|
||||
it('COLLAB-SVC-033: countMessages delegates to CollabService over the shared db', () => {
|
||||
const { user1, trip } = setup();
|
||||
svc.createMessage(trip.id, user1.id, 'one');
|
||||
svc.createMessage(trip.id, user1.id, 'two');
|
||||
|
||||
expect(bridgeCountMessages(trip.id)).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -198,7 +198,9 @@ const filesStub = {
|
||||
findForeignLinkTarget: vi.fn((_tid: number, opts: { reservation_id?: number | null; place_id?: number | null }) =>
|
||||
(Number(opts.reservation_id) === 999 ? 'reservation_id' : Number(opts.place_id) === 999 ? 'place_id' : null)),
|
||||
} as unknown as FilesService;
|
||||
vi.mock('../../../src/services/collabService', () => ({
|
||||
// Collab: injected stub since the collabService DI migration (same behaviors
|
||||
// as the old path mock).
|
||||
const collabStub = {
|
||||
listNotes: vi.fn((tid: number) => [{ id: 1, trip_id: Number(tid), title: 'Note' }]),
|
||||
listPolls: vi.fn((tid: number) => [{ id: 2, trip_id: Number(tid), question: 'Q?' }]),
|
||||
listMessages: vi.fn((tid: number, before?: number) => [{ id: 3, trip_id: Number(tid), text: 'hi', _before: before ?? null }]),
|
||||
@@ -208,7 +210,7 @@ vi.mock('../../../src/services/collabService', () => ({
|
||||
(optionIndex > 5 ? { error: 'Invalid option' } : { poll: { id: Number(pollId), votes: 1 } })),
|
||||
createMessage: vi.fn((tid: number, uid: number, text: string) =>
|
||||
(text === 'toolong' ? { error: 'Message too long' } : { message: { id: 142, trip_id: Number(tid), user_id: uid, text } })),
|
||||
}));
|
||||
} as unknown as CollabService;
|
||||
vi.mock('../../../src/services/tripMembership', () => ({
|
||||
joinTripAsMember: vi.fn((tripId: number, userId: number) => ({ joined: userId !== 5, tripId })), // owner add = no-op
|
||||
}));
|
||||
@@ -309,12 +311,13 @@ import type { AssignmentsService } from '../../../src/nest/assignments/assignmen
|
||||
import type { PluginOAuthService } from '../../../src/nest/plugins/plugin-oauth.service';
|
||||
import type { LlmConfigResolver } from '../../../src/nest/llm-parse/llm-config.resolver';
|
||||
import type { FilesService } from '../../../src/nest/files/files.service';
|
||||
import type { CollabService } from '../../../src/nest/collab/collab.service';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
|
||||
// The factory under test, wired exactly like PluginsModule does — but with the
|
||||
// DI-native domain services replaced by the stubs above. The shim keeps the
|
||||
// ~45 historical call sites unchanged and supplies a default no-op router.
|
||||
const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub);
|
||||
const factory = new PluginHostDepsFactory(budgetStub, reservationsStub, tagsStub, categoriesStub, todoStub, packingStub, oauthStub, dayNotesStub, assignmentsStub, llmConfigStub, new DatabaseService(mockDb), filesStub, collabStub);
|
||||
const stubRouter: PluginCallRouter = { callPlugin: async () => undefined, emitPluginEvent: () => {} };
|
||||
const createRealRpcHost = (id: string, granted: ReadonlySet<string>, router: PluginCallRouter = stubRouter) => factory.create(id, granted, router);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user