mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
6dda05f672
Eight files in services/ hold no state and touch no database: avatarUrl, conflictResult, demo, passwordPolicy, timezoneService, cookie, and the two crypto helpers. They are imported from 65 places, 62 of them already inside src/nest, so having them live in the legacy layer was pure inertia. They stay plain functions rather than becoming providers. Three importers sit outside the container and would have no way to reach an injectable one: db/migrations.ts runs before the app is built, and middleware/auth.ts and middleware/mfaPolicy.ts run beside it. Wrapping these in DI would buy ceremony and nothing else. mfaCrypto and apiKeyCrypto move together into common/crypto — they read the same ENCRYPTION_KEY, and the directory says so. services/ drops from 44 files to 36, 9,870 lines to 8,493. One trap worth recording: src/app-config and src/nest/app-config both exist. cookie.ts imported '../app-config' from services/, which resolved to the former; after the move the same string silently pointed at the latter, which has no readEnv. It is now explicit about which one it wants. Nothing else changes — no signatures, no behaviour, no tests rewritten beyond their import paths.
481 lines
20 KiB
TypeScript
481 lines
20 KiB
TypeScript
/**
|
||
* Unit tests for the DI-native CollabService — COLLAB-SVC-001 to COLLAB-SVC-038
|
||
* (001–030 moved 1:1 from the legacy tests/unit/services/collabService.test.ts;
|
||
* 031–033 (collab.bridge delegation) died with the bridge; 034–038 pin the post-migration
|
||
* hardening: transactional writes, trip-scoped getFormattedNoteById, the
|
||
* integer vote guard and malformed-URL absorption). 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';
|
||
|
||
// ── DB setup ─────────────────────────────────────────────────────────────────
|
||
|
||
const { testDb, dbMock } = vi.hoisted(() => {
|
||
const Database = require('better-sqlite3');
|
||
const db = new Database(':memory:');
|
||
db.exec('PRAGMA journal_mode = WAL');
|
||
db.exec('PRAGMA foreign_keys = ON');
|
||
db.exec('PRAGMA busy_timeout = 5000');
|
||
const mock = {
|
||
db,
|
||
closeDb: () => {},
|
||
reinitialize: () => {},
|
||
getPlaceWithTags: () => null,
|
||
canAccessTrip: (tripId: any, userId: number) =>
|
||
db.prepare(`
|
||
SELECT t.id FROM trips t
|
||
LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ?
|
||
WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)
|
||
`).get(userId, tripId, userId),
|
||
isOwner: (tripId: any, userId: number) =>
|
||
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
|
||
};
|
||
return { testDb: db, dbMock: mock };
|
||
});
|
||
|
||
vi.mock('../../../src/db/database', () => dbMock);
|
||
vi.mock('../../../src/config', () => ({
|
||
JWT_SECRET: 'test-jwt-secret-for-trek-testing-only',
|
||
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
|
||
updateJwtSecret: () => {},
|
||
}));
|
||
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
|
||
|
||
// 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(() => ({})),
|
||
}));
|
||
vi.mock('../../../src/utils/ssrfGuard', () => ({
|
||
checkSsrf: mockCheckSsrf,
|
||
createPinnedDispatcher: mockCreatePinnedDispatcher,
|
||
}));
|
||
|
||
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 } from '../../../src/nest/common/avatarUrl';
|
||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||
import { PermissionsService } from '../../../src/nest/permissions/permissions.service';
|
||
import { CollabService } from '../../../src/nest/collab/collab.service';
|
||
import { RealtimeService } from '../../../src/nest/realtime/realtime.service';
|
||
|
||
const svc = new CollabService(new DatabaseService(testDb), new PermissionsService(new DatabaseService(testDb)), new RealtimeService());
|
||
|
||
beforeAll(() => {
|
||
createTables(testDb);
|
||
runMigrations(testDb);
|
||
});
|
||
|
||
beforeEach(() => {
|
||
resetTestDb(testDb);
|
||
mockCheckSsrf.mockResolvedValue({ allowed: true, resolvedIp: '93.184.216.34' });
|
||
});
|
||
|
||
afterAll(() => {
|
||
testDb.close();
|
||
});
|
||
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
mockCheckSsrf.mockReset();
|
||
mockCheckSsrf.mockResolvedValue({ allowed: true, resolvedIp: '93.184.216.34' });
|
||
});
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||
|
||
function setup() {
|
||
const { user: user1 } = createUser(testDb);
|
||
const { user: user2 } = createUser(testDb);
|
||
const trip = createTrip(testDb, user1.id);
|
||
return { user1, user2, trip };
|
||
}
|
||
|
||
// ── avatarUrl ─────────────────────────────────────────────────────────────────
|
||
|
||
describe('avatarUrl', () => {
|
||
it('COLLAB-SVC-001: returns null when avatar is null', () => {
|
||
expect(avatarUrl({ avatar: null })).toBeNull();
|
||
});
|
||
|
||
it('COLLAB-SVC-002: returns upload path when avatar is set', () => {
|
||
expect(avatarUrl({ avatar: 'abc.jpg' })).toBe('/uploads/avatars/abc.jpg');
|
||
});
|
||
|
||
it('COLLAB-SVC-003: returns null when avatar is empty string', () => {
|
||
expect(avatarUrl({ avatar: '' })).toBeNull();
|
||
});
|
||
});
|
||
|
||
// ── votePoll ──────────────────────────────────────────────────────────────────
|
||
|
||
describe('votePoll', () => {
|
||
it('COLLAB-SVC-004: returns error "closed" when poll is closed', () => {
|
||
const { user1, trip } = setup();
|
||
const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||
svc.closePoll(trip.id, poll!.id);
|
||
|
||
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 = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||
|
||
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 = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||
|
||
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 = 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 = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] });
|
||
|
||
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);
|
||
});
|
||
|
||
it('COLLAB-SVC-009: toggles vote off when voted again on same option', () => {
|
||
const { user1, trip } = setup();
|
||
const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['Yes', 'No'] });
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
// ── listMessages with before cursor ──────────────────────────────────────────
|
||
|
||
describe('listMessages', () => {
|
||
it('COLLAB-SVC-010: returns all messages when no before cursor', () => {
|
||
const { user1, trip } = setup();
|
||
svc.createMessage(trip.id, user1.id, 'Hello');
|
||
svc.createMessage(trip.id, user1.id, 'World');
|
||
|
||
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();
|
||
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 = svc.listMessages(trip.id, id3);
|
||
expect(msgs.length).toBe(2);
|
||
const texts = msgs.map(m => m.text);
|
||
expect(texts).toContain('First');
|
||
expect(texts).toContain('Second');
|
||
expect(texts).not.toContain('Third');
|
||
});
|
||
|
||
it('COLLAB-SVC-012: returns messages in ascending order (reversed after DESC query)', () => {
|
||
const { user1, trip } = setup();
|
||
svc.createMessage(trip.id, user1.id, 'A');
|
||
svc.createMessage(trip.id, user1.id, 'B');
|
||
svc.createMessage(trip.id, user1.id, 'C');
|
||
|
||
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 = 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 = svc.listMessages(trip.id);
|
||
expect(msgs[0].reactions).toBeDefined();
|
||
expect(msgs[0].reactions).toHaveLength(1);
|
||
expect(msgs[0].reactions[0].emoji).toBe('👍');
|
||
});
|
||
});
|
||
|
||
// ── createMessage with invalid replyTo ───────────────────────────────────────
|
||
|
||
describe('createMessage', () => {
|
||
it('COLLAB-SVC-014: returns error when replyTo message does not exist', () => {
|
||
const { user1, trip } = setup();
|
||
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 = 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);
|
||
});
|
||
});
|
||
|
||
// ── deleteMessage ownership check ─────────────────────────────────────────────
|
||
|
||
describe('deleteMessage', () => {
|
||
it('COLLAB-SVC-016: returns error "not_owner" when user does not own message', () => {
|
||
const { user1, user2, trip } = setup();
|
||
const r = svc.createMessage(trip.id, user1.id, 'My message');
|
||
|
||
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 = 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 = svc.createMessage(trip.id, user1.id, 'Delete me');
|
||
|
||
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;
|
||
expect(row.deleted).toBe(1);
|
||
});
|
||
});
|
||
|
||
// ── updateNote partial fields ─────────────────────────────────────────────────
|
||
|
||
describe('updateNote', () => {
|
||
it('COLLAB-SVC-019: updates only title when other fields are undefined', () => {
|
||
const { user1, trip } = setup();
|
||
const note = svc.createNote(trip.id, user1.id, { title: 'Original', content: 'Some content', website: 'https://example.com' });
|
||
|
||
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');
|
||
expect(updated.content).toBe('Some content'); // unchanged
|
||
expect(updated.website).toBe('https://example.com'); // unchanged
|
||
});
|
||
|
||
it('COLLAB-SVC-020: clears content when content is explicitly set to empty string', () => {
|
||
const { user1, trip } = setup();
|
||
const note = svc.createNote(trip.id, user1.id, { title: 'T', content: 'Old 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('');
|
||
});
|
||
|
||
it('COLLAB-SVC-021: updates website when website is defined', () => {
|
||
const { user1, trip } = setup();
|
||
const note = svc.createNote(trip.id, user1.id, { title: 'T' });
|
||
|
||
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');
|
||
});
|
||
|
||
it('COLLAB-SVC-022: clears website when website is explicitly set to empty string', () => {
|
||
const { user1, trip } = setup();
|
||
const note = svc.createNote(trip.id, user1.id, { title: 'T', website: 'https://old.com' });
|
||
|
||
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('');
|
||
});
|
||
|
||
it('COLLAB-SVC-023: returns null when note does not exist', () => {
|
||
const { trip } = setup();
|
||
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 = svc.createNote(trip.id, user1.id, { title: 'T', pinned: false });
|
||
|
||
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);
|
||
});
|
||
});
|
||
|
||
// ── linkPreview ───────────────────────────────────────────────────────────────
|
||
|
||
describe('linkPreview', () => {
|
||
afterEach(() => {
|
||
vi.unstubAllGlobals();
|
||
});
|
||
|
||
it('COLLAB-SVC-025: returns OG title and description from HTML', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
text: async () => `
|
||
<html>
|
||
<head>
|
||
<meta property="og:title" content="Test Title" />
|
||
<meta property="og:description" content="Test Description" />
|
||
<meta property="og:image" content="https://example.com/image.jpg" />
|
||
<meta property="og:site_name" content="Example" />
|
||
</head>
|
||
</html>
|
||
`,
|
||
}));
|
||
|
||
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');
|
||
expect(result.url).toBe('https://example.com/page');
|
||
});
|
||
|
||
it('COLLAB-SVC-026: falls back to <title> tag when no og:title', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
text: async () => `<html><head><title>Page Title</title></head></html>`,
|
||
}));
|
||
|
||
const result = await svc.linkPreview('https://example.com/');
|
||
expect(result.title).toBe('Page Title');
|
||
});
|
||
|
||
it('COLLAB-SVC-027: returns fallback when fetch response is not ok', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: false,
|
||
text: async () => '',
|
||
}));
|
||
|
||
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');
|
||
});
|
||
|
||
it('COLLAB-SVC-028: returns fallback when SSRF check blocks the URL', async () => {
|
||
mockCheckSsrf.mockResolvedValue({ allowed: false, error: 'SSRF blocked' });
|
||
|
||
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 svc.linkPreview('https://example.com/net-error');
|
||
expect(result.title).toBeNull();
|
||
expect(result.url).toBe('https://example.com/net-error');
|
||
});
|
||
|
||
it('COLLAB-SVC-030: falls back to meta description tag when no og:description', async () => {
|
||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
||
ok: true,
|
||
text: async () => `
|
||
<html><head>
|
||
<meta name="description" content="Meta description here" />
|
||
</head></html>
|
||
`,
|
||
}));
|
||
|
||
const result = await svc.linkPreview('https://example.com/meta');
|
||
expect(result.description).toBe('Meta description here');
|
||
});
|
||
});
|
||
|
||
// COLLAB-SVC-031..033 (collab.bridge delegation) were deleted with the bridge —
|
||
// its last consumers (the legacy tripService and the legacy get_trip_summary
|
||
// registrar) migrated into the DI-native TripsService/TripsMcp.
|
||
|
||
// ── Post-migration hardening (transactions, scoping, guards) ──────────────────
|
||
|
||
describe('hardening', () => {
|
||
it('COLLAB-SVC-034: votePoll switch is atomic — prior vote survives a failed INSERT', () => {
|
||
const { user1, trip } = setup();
|
||
const dbs = new DatabaseService(testDb);
|
||
const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService());
|
||
const poll = failing.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||
failing.votePoll(trip.id, poll!.id, user1.id, 0);
|
||
|
||
const realRun = dbs.run.bind(dbs);
|
||
const spy = vi.spyOn(dbs, 'run').mockImplementation((sql: string, ...params: unknown[]) => {
|
||
if (sql.includes('INSERT INTO collab_poll_votes')) throw new Error('boom');
|
||
return realRun(sql, ...params);
|
||
});
|
||
// Single-choice switch: DELETE prior votes, then the INSERT fails — the
|
||
// transaction must roll the DELETE back too.
|
||
expect(() => failing.votePoll(trip.id, poll!.id, user1.id, 1)).toThrow('boom');
|
||
spy.mockRestore();
|
||
|
||
const votes = testDb.prepare('SELECT option_index FROM collab_poll_votes WHERE poll_id = ?').all(poll!.id) as { option_index: number }[];
|
||
expect(votes).toEqual([{ option_index: 0 }]);
|
||
});
|
||
|
||
it('COLLAB-SVC-035: deleteNote is atomic — trip_files rows survive a failed note DELETE', () => {
|
||
const { user1, trip } = setup();
|
||
const dbs = new DatabaseService(testDb);
|
||
const failing = new CollabService(dbs, new PermissionsService(dbs), new RealtimeService());
|
||
const note = failing.createNote(trip.id, user1.id, { title: 'With file' });
|
||
testDb.prepare('INSERT INTO trip_files (trip_id, note_id, filename, original_name) VALUES (?, ?, ?, ?)')
|
||
.run(trip.id, note.id, 'files/a.pdf', 'a.pdf');
|
||
|
||
const realRun = dbs.run.bind(dbs);
|
||
const spy = vi.spyOn(dbs, 'run').mockImplementation((sql: string, ...params: unknown[]) => {
|
||
if (sql.includes('DELETE FROM collab_notes')) throw new Error('boom');
|
||
return realRun(sql, ...params);
|
||
});
|
||
expect(() => failing.deleteNote(trip.id, note.id)).toThrow('boom');
|
||
spy.mockRestore();
|
||
|
||
expect(testDb.prepare('SELECT COUNT(*) as c FROM trip_files WHERE note_id = ?').get(note.id)).toEqual({ c: 1 });
|
||
expect(testDb.prepare('SELECT COUNT(*) as c FROM collab_notes WHERE id = ?').get(note.id)).toEqual({ c: 1 });
|
||
});
|
||
|
||
it('COLLAB-SVC-036: getFormattedNoteById is trip-scoped and null-safe', () => {
|
||
const { user1, trip } = setup();
|
||
const otherTrip = createTrip(testDb, user1.id);
|
||
const note = svc.createNote(trip.id, user1.id, { title: 'Scoped' });
|
||
|
||
expect(svc.getFormattedNoteById(trip.id, note.id)!.title).toBe('Scoped');
|
||
expect(svc.getFormattedNoteById(otherTrip.id, note.id)).toBeNull();
|
||
expect(svc.getFormattedNoteById(trip.id, 9999)).toBeNull();
|
||
});
|
||
|
||
it('COLLAB-SVC-037: votePoll rejects a non-integer option index with "invalid_index"', () => {
|
||
const { user1, trip } = setup();
|
||
const poll = svc.createPoll(trip.id, user1.id, { question: 'Q?', options: ['A', 'B'] });
|
||
|
||
expect(svc.votePoll(trip.id, poll!.id, user1.id, '0' as unknown as number).error).toBe('invalid_index');
|
||
expect(svc.votePoll(trip.id, poll!.id, user1.id, 0.5).error).toBe('invalid_index');
|
||
expect(testDb.prepare('SELECT COUNT(*) as c FROM collab_poll_votes WHERE poll_id = ?').get(poll!.id)).toEqual({ c: 0 });
|
||
});
|
||
|
||
it('COLLAB-SVC-038: linkPreview returns the fallback for a malformed URL without throwing', async () => {
|
||
const result = await svc.linkPreview('not a url');
|
||
expect(result).toEqual({ title: null, description: null, image: null, url: 'not a url' });
|
||
expect(mockCheckSsrf).not.toHaveBeenCalled();
|
||
});
|
||
});
|