chore: apply prettier on the entire project

This commit is contained in:
jubnl
2026-05-25 21:59:42 +02:00
parent c130ed41be
commit 6bcdfbc34b
488 changed files with 82986 additions and 45830 deletions
+43 -12
View File
@@ -2,6 +2,27 @@
* Unit tests for MCP resources (resources.ts).
* Tests all 14 resources via InMemoryTransport + Client.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
createUser,
createTrip,
createDay,
createPlace,
addTripMember,
createBudgetItem,
createPackingItem,
createReservation,
createDayNote,
createCollabNote,
createBucketListItem,
createVisitedCountry,
createDayAssignment,
createDayAccommodation,
} from '../../helpers/factories';
import { createMcpHarness, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -15,13 +36,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
const place: any = db
.prepare(
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
)
.get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
const tags = db
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
.all(placeId);
return {
...place,
category: place.category_id
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
: null,
tags,
};
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -36,12 +73,6 @@ vi.mock('../../../src/config', () => ({
}));
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createBucketListItem, createVisitedCountry, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
import { createMcpHarness, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -433,7 +464,7 @@ describe('Resource: trek://categories', () => {
});
describe('Resource: trek://bucket-list', () => {
it('returns only the current user\'s bucket list items', async () => {
it("returns only the current user's bucket list items", async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
createBucketListItem(testDb, user.id, { name: 'Tokyo' });
@@ -459,7 +490,7 @@ describe('Resource: trek://bucket-list', () => {
});
describe('Resource: trek://visited-countries', () => {
it('returns only the current user\'s visited countries', async () => {
it("returns only the current user's visited countries", async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
createVisitedCountry(testDb, user.id, 'FR');
+2 -1
View File
@@ -2,7 +2,6 @@
* Unit tests for MCP scope helper functions in server/src/mcp/scopes.ts.
* No DB or mocks needed — pure functions only.
*/
import { describe, it, expect } from 'vitest';
import {
validateScopes,
canReadTrips,
@@ -14,6 +13,8 @@ import {
SCOPE_INFO,
} from '../../../src/mcp/scopes';
import { describe, it, expect } from 'vitest';
// ---------------------------------------------------------------------------
// ALL_SCOPES
// ---------------------------------------------------------------------------
+11 -4
View File
@@ -2,9 +2,10 @@
* Unit tests for MCP sessionManager — SESS-001 to SESS-010.
* Covers revokeUserSessions and revokeUserSessionsForClient.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { sessions, revokeUserSessions, revokeUserSessionsForClient, McpSession } from '../../../src/mcp/sessionManager';
import { describe, it, expect, vi, beforeEach } from 'vitest';
function makeSession(overrides: Partial<McpSession> = {}): McpSession {
return {
server: { close: vi.fn() } as any,
@@ -60,7 +61,9 @@ describe('revokeUserSessions', () => {
it('SESS-005: tolerates server.close() throwing (swallows error)', () => {
const s = makeSession({ userId: 1 });
(s.server.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('close failed'); });
(s.server.close as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error('close failed');
});
sessions.set('sid-1', s);
expect(() => revokeUserSessions(1)).not.toThrow();
@@ -69,7 +72,9 @@ describe('revokeUserSessions', () => {
it('SESS-006: tolerates transport.close() throwing (swallows error)', () => {
const s = makeSession({ userId: 1 });
(s.transport.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('transport error'); });
(s.transport.close as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error('transport error');
});
sessions.set('sid-1', s);
expect(() => revokeUserSessions(1)).not.toThrow();
@@ -112,7 +117,9 @@ describe('revokeUserSessionsForClient', () => {
it('SESS-010: tolerates close() throwing for matched sessions', () => {
const s = makeSession({ userId: 1, clientId: 'c' });
(s.server.close as ReturnType<typeof vi.fn>).mockImplementation(() => { throw new Error('x'); });
(s.server.close as ReturnType<typeof vi.fn>).mockImplementation(() => {
throw new Error('x');
});
sessions.set('sid-1', s);
expect(() => revokeUserSessionsForClient(1, 'c')).not.toThrow();
@@ -1,6 +1,12 @@
/**
* Unit tests for MCP addon gating and scope enforcement in tools.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -15,7 +21,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -41,12 +51,6 @@ vi.mock('../../../src/services/adminService', () => ({
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
}));
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 { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -62,13 +66,13 @@ afterAll(() => {
testDb.close();
});
async function withHarness(
userId: number,
fn: (h: McpHarness) => Promise<void>,
scopes?: string[] | null
) {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>, scopes?: string[] | null) {
const h = await createMcpHarness({ userId, withResources: false, scopes: scopes ?? null });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -153,7 +157,10 @@ describe('Budget tools — addon gating', () => {
isAddonEnabledMock.mockImplementation((id: string) => id !== 'budget');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_budget_item', arguments: { tripId: 1, name: 'Test', total_price: 100 } });
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: 1, name: 'Test', total_price: 100 },
});
expect(result.isError).toBe(true);
});
});
@@ -170,7 +177,10 @@ describe('Packing tools — addon gating', () => {
isAddonEnabledMock.mockImplementation((id: string) => id !== 'packing');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_packing_item', arguments: { tripId: 1, name: 'Sunscreen' } });
const result = await h.client.callTool({
name: 'create_packing_item',
arguments: { tripId: 1, name: 'Sunscreen' },
});
expect(result.isError).toBe(true);
});
});
@@ -187,7 +197,10 @@ describe('Collab tools — addon gating', () => {
isAddonEnabledMock.mockImplementation((id: string) => id !== 'collab');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_collab_note', arguments: { tripId: 1, title: 'Test Note' } });
const result = await h.client.callTool({
name: 'create_collab_note',
arguments: { tripId: 1, title: 'Test Note' },
});
expect(result.isError).toBe(true);
});
});
@@ -229,51 +242,74 @@ describe('Scope enforcement in tools', () => {
it('with scopes trips:read, create_trip is not registered (write not in scopes)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Should Fail' } });
expect(result.isError).toBe(true);
}, ['trips:read']);
await withHarness(
user.id,
async (h) => {
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Should Fail' } });
expect(result.isError).toBe(true);
},
['trips:read'],
);
});
it('with scopes trips:write, create_trip is registered and works', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'My Trip' } });
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
expect(data.trip.title).toBe('My Trip');
}, ['trips:write']);
await withHarness(
user.id,
async (h) => {
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'My Trip' } });
expect(result.isError).toBeFalsy();
const data = parseToolResult(result) as any;
expect(data.trip.title).toBe('My Trip');
},
['trips:write'],
);
});
it('with scopes null (full access), create_trip is registered', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Full Access Trip' } });
expect(result.isError).toBeFalsy();
}, null);
await withHarness(
user.id,
async (h) => {
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Full Access Trip' } });
expect(result.isError).toBeFalsy();
},
null,
);
});
it('with scopes trips:read, create_budget_item is not registered (budget:write not in scopes)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_budget_item', arguments: { tripId: 1, name: 'Hotel', total_price: 200 } });
expect(result.isError).toBe(true);
}, ['trips:read']);
await withHarness(
user.id,
async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: 1, name: 'Hotel', total_price: 200 },
});
expect(result.isError).toBe(true);
},
['trips:read'],
);
});
it('with scopes budget:write and trips:read, create_budget_item is registered (budget addon enabled)', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Budget Trip' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Hotel', total_price: 200 },
});
expect(result.isError).toBeFalsy();
}, ['budget:write', 'trips:read']);
await withHarness(
user.id,
async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Hotel', total_price: 200 },
});
expect(result.isError).toBeFalsy();
},
['budget:write', 'trips:read'],
);
});
});
@@ -2,6 +2,19 @@
* Unit tests for MCP extra assignment/reservation tools:
* move_assignment, get_assignment_participants, set_assignment_participants, reorder_reservations.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
createUser,
createTrip,
createDay,
createPlace,
createDayAssignment,
createReservation,
} from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -16,7 +29,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -33,12 +50,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createReservation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -56,7 +67,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -75,7 +90,13 @@ describe('Tool: move_assignment', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'move_assignment',
arguments: { tripId: trip.id, assignmentId: assignment.id, newDayId: day2.id, oldDayId: day1.id, orderIndex: 0 },
arguments: {
tripId: trip.id,
assignmentId: assignment.id,
newDayId: day2.id,
oldDayId: day1.id,
orderIndex: 0,
},
});
const data = parseToolResult(result) as any;
expect(data.assignment).toBeDefined();
@@ -142,7 +163,10 @@ describe('Tool: get_assignment_participants', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_assignment_participants', arguments: { tripId: trip.id, assignmentId: 1 } });
const result = await h.client.callTool({
name: 'get_assignment_participants',
arguments: { tripId: trip.id, assignmentId: 1 },
});
expect(result.isError).toBe(true);
});
});
@@ -177,7 +201,9 @@ describe('Tool: set_assignment_participants', () => {
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
// First set
testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignment.id, user.id);
testDb
.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)')
.run(assignment.id, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_assignment_participants',
+69 -22
View File
@@ -2,6 +2,12 @@
* Unit tests for MCP assignment tools: assign_place_to_day, unassign_place,
* reorder_day_assignments, update_assignment_time.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, createDay, createPlace, createDayAssignment } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -16,7 +22,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -33,12 +43,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -56,7 +60,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -107,7 +115,10 @@ describe('Tool: assign_place_to_day', () => {
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'assign_place_to_day', arguments: { tripId: trip.id, dayId: day.id, placeId: place.id } });
await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: day.id, placeId: place.id },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:created', expect.any(Object));
});
});
@@ -151,7 +162,10 @@ describe('Tool: assign_place_to_day', () => {
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'assign_place_to_day', arguments: { tripId: trip.id, dayId: day.id, placeId: place.id } });
const result = await h.client.callTool({
name: 'assign_place_to_day',
arguments: { tripId: trip.id, dayId: day.id, placeId: place.id },
});
expect(result.isError).toBe(true);
});
});
@@ -187,7 +201,10 @@ describe('Tool: unassign_place', () => {
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id } });
await h.client.callTool({
name: 'unassign_place',
arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:deleted', expect.any(Object));
});
});
@@ -197,7 +214,10 @@ describe('Tool: unassign_place', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: 99999 } });
const result = await h.client.callTool({
name: 'unassign_place',
arguments: { tripId: trip.id, dayId: day.id, assignmentId: 99999 },
});
expect(result.isError).toBe(true);
});
});
@@ -210,7 +230,10 @@ describe('Tool: unassign_place', () => {
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id } });
const result = await h.client.callTool({
name: 'unassign_place',
arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id },
});
expect(result.isError).toBe(true);
});
});
@@ -238,8 +261,12 @@ describe('Tool: reorder_day_assignments', () => {
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const a1Updated = testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a1.id) as { order_index: number };
const a2Updated = testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a2.id) as { order_index: number };
const a1Updated = testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a1.id) as {
order_index: number;
};
const a2Updated = testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a2.id) as {
order_index: number;
};
expect(a2Updated.order_index).toBe(0);
expect(a1Updated.order_index).toBe(1);
});
@@ -252,7 +279,10 @@ describe('Tool: reorder_day_assignments', () => {
const place = createPlace(testDb, trip.id);
const a = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [a.id] } });
await h.client.callTool({
name: 'reorder_day_assignments',
arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [a.id] },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:reordered', expect.any(Object));
});
});
@@ -263,7 +293,10 @@ describe('Tool: reorder_day_assignments', () => {
const trip2 = createTrip(testDb, user.id);
const day = createDay(testDb, trip2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip1.id, dayId: day.id, assignmentIds: [1] } });
const result = await h.client.callTool({
name: 'reorder_day_assignments',
arguments: { tripId: trip1.id, dayId: day.id, assignmentIds: [1] },
});
expect(result.isError).toBe(true);
});
});
@@ -274,7 +307,10 @@ describe('Tool: reorder_day_assignments', () => {
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [1] } });
const result = await h.client.callTool({
name: 'reorder_day_assignments',
arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [1] },
});
expect(result.isError).toBe(true);
});
});
@@ -309,7 +345,9 @@ describe('Tool: update_assignment_time', () => {
const day = createDay(testDb, trip.id);
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('09:00', '11:00', assignment.id);
testDb
.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?')
.run('09:00', '11:00', assignment.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -329,7 +367,10 @@ describe('Tool: update_assignment_time', () => {
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '10:00' } });
await h.client.callTool({
name: 'update_assignment_time',
arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '10:00' },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:updated', expect.any(Object));
});
});
@@ -338,7 +379,10 @@ describe('Tool: update_assignment_time', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: 99999, place_time: '09:00' } });
const result = await h.client.callTool({
name: 'update_assignment_time',
arguments: { tripId: trip.id, assignmentId: 99999, place_time: '09:00' },
});
expect(result.isError).toBe(true);
});
});
@@ -351,7 +395,10 @@ describe('Tool: update_assignment_time', () => {
const place = createPlace(testDb, trip.id);
const assignment = createDayAssignment(testDb, day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '09:00' } });
const result = await h.client.callTool({
name: 'update_assignment_time',
arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '09:00' },
});
expect(result.isError).toBe(true);
});
});
@@ -4,6 +4,12 @@
* get_country_atlas_places, update_bucket_list_item.
* Also covers resources trek://atlas/stats and trek://atlas/regions.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -18,7 +24,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -40,12 +50,6 @@ vi.mock('../../../src/services/adminService', () => ({
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -63,12 +67,20 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -103,9 +115,9 @@ describe('Tool: list_visited_regions', () => {
it('returns regions after they have been inserted', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'FR-75', 'Paris', 'FR');
testDb
.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)')
.run(user.id, 'FR-75', 'Paris', 'FR');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} });
const data = parseToolResult(result) as any;
@@ -132,7 +144,9 @@ describe('Tool: mark_region_visited', () => {
expect(data.region.region_code).toBe('US-CA');
expect(data.region.region_name).toBe('California');
expect(data.region.country_code).toBe('US');
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA');
const row = testDb
.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?')
.get(user.id, 'US-CA');
expect(row).toBeTruthy();
});
});
@@ -157,9 +171,9 @@ describe('Tool: mark_region_visited', () => {
describe('Tool: unmark_region_visited', () => {
it('removes region and returns success', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'IT-LO', 'Lombardy', 'IT');
testDb
.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)')
.run(user.id, 'IT-LO', 'Lombardy', 'IT');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'unmark_region_visited',
@@ -167,7 +181,9 @@ describe('Tool: unmark_region_visited', () => {
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'IT-LO');
const row = testDb
.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?')
.get(user.id, 'IT-LO');
expect(row).toBeUndefined();
});
});
@@ -211,9 +227,9 @@ describe('Tool: get_country_atlas_places', () => {
describe('Tool: update_bucket_list_item', () => {
it('updates notes and returns item', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Visit Tokyo');
const r = testDb
.prepare('INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)')
.run(user.id, 'Visit Tokyo');
const itemId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -228,9 +244,9 @@ describe('Tool: update_bucket_list_item', () => {
it('updates name of existing item', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Old Name');
const r = testDb
.prepare('INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)')
.run(user.id, 'Old Name');
const itemId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -256,9 +272,9 @@ describe('Tool: update_bucket_list_item', () => {
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const r = testDb.prepare(
'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)'
).run(user.id, 'Bucket Item');
const r = testDb
.prepare('INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)')
.run(user.id, 'Bucket Item');
const itemId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -301,9 +317,9 @@ describe('Resource: trek://atlas/regions', () => {
it('returns inserted regions', async () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)'
).run(user.id, 'ES-CT', 'Catalonia', 'ES');
testDb
.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)')
.run(user.id, 'ES-CT', 'Catalonia', 'ES');
await withResourceHarness(user.id, async (h) => {
const result = await h.client.readResource({ uri: 'trek://atlas/regions' });
const data = parseResourceResult(result) as any;
+27 -11
View File
@@ -2,6 +2,12 @@
* Unit tests for MCP atlas and bucket list tools:
* mark_country_visited, unmark_country_visited, create_bucket_list_item, delete_bucket_list_item.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -16,7 +22,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -32,12 +42,6 @@ vi.mock('../../../src/config', () => ({
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createBucketListItem, createVisitedCountry } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -54,7 +58,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -69,7 +77,9 @@ describe('Tool: mark_country_visited', () => {
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.country_code).toBe('FR');
const row = testDb.prepare('SELECT country_code FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'FR');
const row = testDb
.prepare('SELECT country_code FROM visited_countries WHERE user_id = ? AND country_code = ?')
.get(user.id, 'FR');
expect(row).toBeTruthy();
});
});
@@ -81,7 +91,11 @@ describe('Tool: mark_country_visited', () => {
const result = await h.client.callTool({ name: 'mark_country_visited', arguments: { country_code: 'JP' } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const count = (testDb.prepare('SELECT COUNT(*) as c FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'JP') as { c: number }).c;
const count = (
testDb
.prepare('SELECT COUNT(*) as c FROM visited_countries WHERE user_id = ? AND country_code = ?')
.get(user.id, 'JP') as { c: number }
).c;
expect(count).toBe(1);
});
});
@@ -108,7 +122,9 @@ describe('Tool: unmark_country_visited', () => {
const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'ES' } });
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT country_code FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'ES');
const row = testDb
.prepare('SELECT country_code FROM visited_countries WHERE user_id = ? AND country_code = ?')
.get(user.id, 'ES');
expect(row).toBeUndefined();
});
});
@@ -3,6 +3,12 @@
* set_budget_item_members, toggle_budget_member_paid.
* Resources: trek://trips/{tripId}/budget/per-person, trek://trips/{tripId}/budget/settlement.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -17,7 +23,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -34,12 +44,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -57,12 +61,20 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -97,7 +109,9 @@ describe('Tool: set_budget_item_members', () => {
});
const data = parseToolResult(result) as any;
expect(data.item).toBeDefined();
const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_item_members WHERE budget_item_id = ?').get(item.id) as any;
const remaining = testDb
.prepare('SELECT count(*) as cnt FROM budget_item_members WHERE budget_item_id = ?')
.get(item.id) as any;
expect(remaining.cnt).toBe(0);
});
});
@@ -141,7 +155,9 @@ describe('Tool: toggle_budget_member_paid', () => {
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id, { total_price: 200 });
// Add member first
testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)').run(item.id, user.id);
testDb
.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)')
.run(item.id, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'toggle_budget_member_paid',
+59 -18
View File
@@ -1,6 +1,12 @@
/**
* Unit tests for MCP budget tools: create_budget_item, update_budget_item, delete_budget_item.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -15,7 +21,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -32,12 +42,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createBudgetItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -55,7 +59,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -69,7 +77,13 @@ describe('Tool: create_budget_item', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Hotel Paris', category: 'Accommodation', total_price: 500, note: 'Prepaid' },
arguments: {
tripId: trip.id,
name: 'Hotel Paris',
category: 'Accommodation',
total_price: 500,
note: 'Prepaid',
},
});
const data = parseToolResult(result) as any;
expect(data.item.name).toBe('Hotel Paris');
@@ -96,7 +110,10 @@ describe('Tool: create_budget_item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'create_budget_item', arguments: { tripId: trip.id, name: 'Taxi', total_price: 25 } });
await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Taxi', total_price: 25 },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:created', expect.any(Object));
});
});
@@ -106,7 +123,10 @@ describe('Tool: create_budget_item', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_budget_item', arguments: { tripId: trip.id, name: 'Hack', total_price: 0 } });
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'Hack', total_price: 0 },
});
expect(result.isError).toBe(true);
});
});
@@ -116,7 +136,10 @@ describe('Tool: create_budget_item', () => {
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_budget_item', arguments: { tripId: trip.id, name: 'X', total_price: 0 } });
const result = await h.client.callTool({
name: 'create_budget_item',
arguments: { tripId: trip.id, name: 'X', total_price: 0 },
});
expect(result.isError).toBe(true);
});
});
@@ -148,7 +171,10 @@ describe('Tool: update_budget_item', () => {
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_budget_item', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:updated', expect.any(Object));
});
});
@@ -157,7 +183,10 @@ describe('Tool: update_budget_item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_budget_item', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } });
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: 99999, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -168,7 +197,10 @@ describe('Tool: update_budget_item', () => {
const trip = createTrip(testDb, other.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_budget_item', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
const result = await h.client.callTool({
name: 'update_budget_item',
arguments: { tripId: trip.id, itemId: item.id, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -184,7 +216,10 @@ describe('Tool: delete_budget_item', () => {
const trip = createTrip(testDb, user.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } });
const result = await h.client.callTool({
name: 'delete_budget_item',
arguments: { tripId: trip.id, itemId: item.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(item.id)).toBeUndefined();
@@ -205,7 +240,10 @@ describe('Tool: delete_budget_item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: 99999 } });
const result = await h.client.callTool({
name: 'delete_budget_item',
arguments: { tripId: trip.id, itemId: 99999 },
});
expect(result.isError).toBe(true);
});
});
@@ -216,7 +254,10 @@ describe('Tool: delete_budget_item', () => {
const trip = createTrip(testDb, other.id);
const item = createBudgetItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } });
const result = await h.client.callTool({
name: 'delete_budget_item',
arguments: { tripId: trip.id, itemId: item.id },
});
expect(result.isError).toBe(true);
});
});
@@ -5,6 +5,12 @@
* delete_collab_message, react_collab_message.
* Resources: trek://trips/{tripId}/collab/polls, trek://trips/{tripId}/collab/messages.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -19,7 +25,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -41,12 +51,6 @@ vi.mock('../../../src/services/adminService', () => ({
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
}));
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 { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -64,12 +68,20 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -162,9 +174,13 @@ describe('Tool: vote_collab_poll', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a poll directly in the DB
const pollId = (testDb.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Best city?', JSON.stringify(['Paris', 'Rome'])) as any).lastInsertRowid;
const pollId = (
testDb
.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
)
.run(trip.id, user.id, 'Best city?', JSON.stringify(['Paris', 'Rome'])) as any
).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -199,9 +215,13 @@ describe('Tool: close_collab_poll', () => {
it('sets closed flag and broadcasts collab:poll:closed', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const pollId = (testDb.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Vote now?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid;
const pollId = (
testDb
.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
)
.run(trip.id, user.id, 'Vote now?', JSON.stringify(['Yes', 'No'])) as any
).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -245,9 +265,13 @@ describe('Tool: delete_collab_poll', () => {
it('removes poll and broadcasts collab:poll:deleted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const pollId = (testDb.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Delete me?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid;
const pollId = (
testDb
.prepare(
`INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))`,
)
.run(trip.id, user.id, 'Delete me?', JSON.stringify(['Yes', 'No'])) as any
).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -256,7 +280,11 @@ describe('Tool: delete_collab_poll', () => {
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:deleted', expect.objectContaining({ pollId: Number(pollId) }));
expect(broadcastMock).toHaveBeenCalledWith(
trip.id,
'collab:poll:deleted',
expect.objectContaining({ pollId: Number(pollId) }),
);
expect(testDb.prepare('SELECT id FROM collab_polls WHERE id = ?').get(Number(pollId))).toBeUndefined();
});
});
@@ -324,9 +352,11 @@ describe('Tool: send_collab_message', () => {
it('sends message with replyTo when parent exists', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Original message') as any).lastInsertRowid;
const msgId = (
testDb
.prepare(`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`)
.run(trip.id, user.id, 'Original message') as any
).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -356,7 +386,10 @@ describe('Tool: send_collab_message', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'send_collab_message', arguments: { tripId: trip.id, text: 'Hi' } });
const result = await h.client.callTool({
name: 'send_collab_message',
arguments: { tripId: trip.id, text: 'Hi' },
});
expect(result.isError).toBe(true);
});
});
@@ -370,9 +403,11 @@ describe('Tool: delete_collab_message', () => {
it('soft-deletes message and broadcasts collab:message:deleted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'To be deleted') as any).lastInsertRowid;
const msgId = (
testDb
.prepare(`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`)
.run(trip.id, user.id, 'To be deleted') as any
).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -391,9 +426,11 @@ describe('Tool: delete_collab_message', () => {
const trip = createTrip(testDb, user.id);
// Add other as trip member
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, other.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'Owner message') as any).lastInsertRowid;
const msgId = (
testDb
.prepare(`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`)
.run(trip.id, user.id, 'Owner message') as any
).lastInsertRowid;
await withHarness(other.id, async (h) => {
const result = await h.client.callTool({
@@ -409,7 +446,10 @@ describe('Tool: delete_collab_message', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_collab_message', arguments: { tripId: trip.id, messageId: 1 } });
const result = await h.client.callTool({
name: 'delete_collab_message',
arguments: { tripId: trip.id, messageId: 1 },
});
expect(result.isError).toBe(true);
});
});
@@ -423,9 +463,11 @@ describe('Tool: react_collab_message', () => {
it('toggles reaction and broadcasts collab:message:reacted', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const msgId = (testDb.prepare(
`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`
).run(trip.id, user.id, 'React to me') as any).lastInsertRowid;
const msgId = (
testDb
.prepare(`INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))`)
.run(trip.id, user.id, 'React to me') as any
).lastInsertRowid;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -455,7 +497,10 @@ describe('Tool: react_collab_message', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'react_collab_message', arguments: { tripId: trip.id, messageId: 1, emoji: '👍' } });
const result = await h.client.callTool({
name: 'react_collab_message',
arguments: { tripId: trip.id, messageId: 1, emoji: '👍' },
});
expect(result.isError).toBe(true);
});
});
@@ -3,6 +3,12 @@
* create_day, delete_day,
* create_accommodation, update_accommodation, delete_accommodation.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, createDay, createPlace, createDayAccommodation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -17,7 +23,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -34,12 +44,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAccommodation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -57,7 +61,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -277,7 +285,11 @@ describe('Tool: delete_accommodation', () => {
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', expect.objectContaining({ id: acc.id }));
expect(broadcastMock).toHaveBeenCalledWith(
trip.id,
'accommodation:deleted',
expect.objectContaining({ id: acc.id }),
);
expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(acc.id)).toBeUndefined();
});
});
@@ -287,7 +299,10 @@ describe('Tool: delete_accommodation', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_accommodation', arguments: { tripId: trip.id, accommodationId: 1 } });
const result = await h.client.callTool({
name: 'delete_accommodation',
arguments: { tripId: trip.id, accommodationId: 1 },
});
expect(result.isError).toBe(true);
});
});
+24 -10
View File
@@ -1,6 +1,12 @@
/**
* Unit tests for MCP day tools: update_day.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, createDay } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -15,7 +21,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -32,12 +42,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -55,7 +59,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -124,7 +132,10 @@ describe('Tool: update_day', () => {
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_day', arguments: { tripId: trip.id, dayId: day.id, title: 'X' } });
const result = await h.client.callTool({
name: 'update_day',
arguments: { tripId: trip.id, dayId: day.id, title: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -135,7 +146,10 @@ describe('Tool: update_day', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_day', arguments: { tripId: trip.id, dayId: day.id, title: 'X' } });
const result = await h.client.callTool({
name: 'update_day',
arguments: { tripId: trip.id, dayId: day.id, title: 'X' },
});
expect(result.isError).toBe(true);
});
});
+93 -29
View File
@@ -2,6 +2,12 @@
* Unit tests for MCP note tools: create_day_note, update_day_note, delete_day_note,
* create_collab_note, update_collab_note, delete_collab_note.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, createDay, createDayNote, createCollabNote } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -16,7 +22,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -40,12 +50,6 @@ vi.mock('fs', async (importOriginal) => {
return { ...actual, unlinkSync: unlinkSyncMock };
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createDayNote, createCollabNote } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -64,7 +68,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -118,7 +126,10 @@ describe('Tool: create_day_note', () => {
const trip2 = createTrip(testDb, user.id);
const dayFromTrip2 = createDay(testDb, trip2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_day_note', arguments: { tripId: trip1.id, dayId: dayFromTrip2.id, text: 'Note' } });
const result = await h.client.callTool({
name: 'create_day_note',
arguments: { tripId: trip1.id, dayId: dayFromTrip2.id, text: 'Note' },
});
expect(result.isError).toBe(true);
});
});
@@ -129,7 +140,10 @@ describe('Tool: create_day_note', () => {
const trip = createTrip(testDb, other.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_day_note', arguments: { tripId: trip.id, dayId: day.id, text: 'X' } });
const result = await h.client.callTool({
name: 'create_day_note',
arguments: { tripId: trip.id, dayId: day.id, text: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -178,7 +192,10 @@ describe('Tool: update_day_note', () => {
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Updated' } });
await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Updated' },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:updated', expect.any(Object));
});
});
@@ -188,7 +205,10 @@ describe('Tool: update_day_note', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: 99999, text: 'X' } });
const result = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: 99999, text: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -200,7 +220,10 @@ describe('Tool: update_day_note', () => {
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'X' } });
const result = await h.client.callTool({
name: 'update_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -217,7 +240,10 @@ describe('Tool: delete_day_note', () => {
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } });
const result = await h.client.callTool({
name: 'delete_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined();
@@ -230,7 +256,10 @@ describe('Tool: delete_day_note', () => {
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } });
await h.client.callTool({
name: 'delete_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:deleted', expect.any(Object));
});
});
@@ -240,7 +269,10 @@ describe('Tool: delete_day_note', () => {
const trip = createTrip(testDb, user.id);
const day = createDay(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: 99999 } });
const result = await h.client.callTool({
name: 'delete_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: 99999 },
});
expect(result.isError).toBe(true);
});
});
@@ -252,7 +284,10 @@ describe('Tool: delete_day_note', () => {
const day = createDay(testDb, trip.id);
const note = createDayNote(testDb, day.id, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } });
const result = await h.client.callTool({
name: 'delete_day_note',
arguments: { tripId: trip.id, dayId: day.id, noteId: note.id },
});
expect(result.isError).toBe(true);
});
});
@@ -283,7 +318,10 @@ describe('Tool: create_collab_note', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_collab_note', arguments: { tripId: trip.id, title: 'Quick note' } });
const result = await h.client.callTool({
name: 'create_collab_note',
arguments: { tripId: trip.id, title: 'Quick note' },
});
const data = parseToolResult(result) as any;
expect(data.note.category).toBe('General');
expect(data.note.color).toBe('#6366f1');
@@ -304,7 +342,10 @@ describe('Tool: create_collab_note', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_collab_note', arguments: { tripId: trip.id, title: 'X' } });
const result = await h.client.callTool({
name: 'create_collab_note',
arguments: { tripId: trip.id, title: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -336,7 +377,10 @@ describe('Tool: update_collab_note', () => {
const trip = createTrip(testDb, user.id);
const note = createCollabNote(testDb, trip.id, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'Updated' } });
await h.client.callTool({
name: 'update_collab_note',
arguments: { tripId: trip.id, noteId: note.id, title: 'Updated' },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:note:updated', expect.any(Object));
});
});
@@ -345,7 +389,10 @@ describe('Tool: update_collab_note', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: 99999, title: 'X' } });
const result = await h.client.callTool({
name: 'update_collab_note',
arguments: { tripId: trip.id, noteId: 99999, title: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -356,7 +403,10 @@ describe('Tool: update_collab_note', () => {
const trip = createTrip(testDb, other.id);
const note = createCollabNote(testDb, trip.id, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'X' } });
const result = await h.client.callTool({
name: 'update_collab_note',
arguments: { tripId: trip.id, noteId: note.id, title: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -372,7 +422,10 @@ describe('Tool: delete_collab_note', () => {
const trip = createTrip(testDb, user.id);
const note = createCollabNote(testDb, trip.id, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
const result = await h.client.callTool({
name: 'delete_collab_note',
arguments: { tripId: trip.id, noteId: note.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined();
@@ -384,12 +437,17 @@ describe('Tool: delete_collab_note', () => {
const trip = createTrip(testDb, user.id);
const note = createCollabNote(testDb, trip.id, user.id);
// Insert a trip_file linked to this note
testDb.prepare(
`INSERT INTO trip_files (trip_id, note_id, filename, original_name, mime_type, file_size) VALUES (?, ?, ?, ?, ?, ?)`
).run(trip.id, note.id, 'test-file.pdf', 'document.pdf', 'application/pdf', 1024);
testDb
.prepare(
`INSERT INTO trip_files (trip_id, note_id, filename, original_name, mime_type, file_size) VALUES (?, ?, ?, ?, ?, ?)`,
)
.run(trip.id, note.id, 'test-file.pdf', 'document.pdf', 'application/pdf', 1024);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
const result = await h.client.callTool({
name: 'delete_collab_note',
arguments: { tripId: trip.id, noteId: note.id },
});
expect((parseToolResult(result) as any).success).toBe(true);
});
@@ -413,7 +471,10 @@ describe('Tool: delete_collab_note', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: 99999 } });
const result = await h.client.callTool({
name: 'delete_collab_note',
arguments: { tripId: trip.id, noteId: 99999 },
});
expect(result.isError).toBe(true);
});
});
@@ -424,7 +485,10 @@ describe('Tool: delete_collab_note', () => {
const trip = createTrip(testDb, other.id);
const note = createCollabNote(testDb, trip.id, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } });
const result = await h.client.callTool({
name: 'delete_collab_note',
arguments: { tripId: trip.id, noteId: note.id },
});
expect(result.isError).toBe(true);
});
});
@@ -5,6 +5,12 @@
* delete_all_notifications.
* Also covers the resource trek://notifications/in-app.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -19,7 +25,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -35,12 +45,6 @@ vi.mock('../../../src/config', () => ({
vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -60,28 +64,38 @@ afterAll(() => {
// ---------------------------------------------------------------------------
function createNotification(db: any, userId: number, overrides: any = {}) {
const r = db.prepare(
`INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read)
VALUES (?, ?, ?, ?, ?, ?, 0)`
).run(
overrides.type ?? 'simple',
overrides.scope ?? 'user',
overrides.target ?? 0,
userId,
overrides.title_key ?? 'notification.test.title',
overrides.text_key ?? 'notification.test.body'
);
const r = db
.prepare(
`INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read)
VALUES (?, ?, ?, ?, ?, ?, 0)`,
)
.run(
overrides.type ?? 'simple',
overrides.scope ?? 'user',
overrides.target ?? 0,
userId,
overrides.title_key ?? 'notification.test.title',
overrides.text_key ?? 'notification.test.body',
);
return db.prepare('SELECT * FROM notifications WHERE id = ?').get(r.lastInsertRowid);
}
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withTools: false, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -240,7 +254,11 @@ describe('Tool: mark_all_notifications_read', () => {
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(data.count).toBe(3);
const unread = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ? AND is_read = 0').get(user.id) as any).c;
const unread = (
testDb
.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ? AND is_read = 0')
.get(user.id) as any
).c;
expect(unread).toBe(0);
});
});
@@ -5,6 +5,12 @@
* set_packing_category_assignees, apply_packing_template, save_packing_template,
* bulk_import_packing.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -19,7 +25,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -36,12 +46,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -59,7 +63,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -119,7 +127,9 @@ describe('Tool: list_packing_bags', () => {
it('returns bags that exist', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Carry-on', '#ff0000');
testDb
.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)')
.run(trip.id, 'Carry-on', '#ff0000');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'list_packing_bags',
@@ -187,7 +197,9 @@ describe('Tool: update_packing_bag', () => {
it('updates bag name and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Old Name', '#aabbcc');
const r = testDb
.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)')
.run(trip.id, 'Old Name', '#aabbcc');
const bag = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(r.lastInsertRowid) as any;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -223,7 +235,9 @@ describe('Tool: delete_packing_bag', () => {
it('deletes a bag and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Delete Me', '#000000');
const r = testDb
.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)')
.run(trip.id, 'Delete Me', '#000000');
const bagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -259,7 +273,9 @@ describe('Tool: set_bag_members', () => {
it('sets bag members and broadcasts', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
const r = testDb
.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)')
.run(trip.id, 'My Bag', '#123456');
const bagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -275,7 +291,9 @@ describe('Tool: set_bag_members', () => {
it('clears bag members when passed empty array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456');
const r = testDb
.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)')
.run(trip.id, 'My Bag', '#123456');
const bagId = r.lastInsertRowid as number;
testDb.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)').run(bagId, user.id);
await withHarness(user.id, async (h) => {
@@ -330,7 +348,9 @@ describe('Tool: set_packing_category_assignees', () => {
it('clears assignees when passed empty array', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Clothing', user.id);
testDb
.prepare('INSERT INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)')
.run(trip.id, 'Clothing', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_packing_category_assignees',
+60 -19
View File
@@ -2,6 +2,12 @@
* Unit tests for MCP packing tools: create_packing_item, update_packing_item,
* toggle_packing_item, delete_packing_item.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -16,7 +22,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -33,12 +43,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPackingItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -56,7 +60,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -106,7 +114,10 @@ describe('Tool: create_packing_item', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_packing_item', arguments: { tripId: trip.id, name: 'X' } });
const result = await h.client.callTool({
name: 'create_packing_item',
arguments: { tripId: trip.id, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -116,7 +127,10 @@ describe('Tool: create_packing_item', () => {
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_packing_item', arguments: { tripId: trip.id, name: 'X' } });
const result = await h.client.callTool({
name: 'create_packing_item',
arguments: { tripId: trip.id, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -147,7 +161,10 @@ describe('Tool: update_packing_item', () => {
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
await h.client.callTool({
name: 'update_packing_item',
arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
});
});
@@ -156,7 +173,10 @@ describe('Tool: update_packing_item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } });
const result = await h.client.callTool({
name: 'update_packing_item',
arguments: { tripId: trip.id, itemId: 99999, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -167,7 +187,10 @@ describe('Tool: update_packing_item', () => {
const trip = createTrip(testDb, other.id);
const item = createPackingItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
const result = await h.client.callTool({
name: 'update_packing_item',
arguments: { tripId: trip.id, itemId: item.id, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -212,7 +235,10 @@ describe('Tool: toggle_packing_item', () => {
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: item.id, checked: true } });
await h.client.callTool({
name: 'toggle_packing_item',
arguments: { tripId: trip.id, itemId: item.id, checked: true },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object));
});
});
@@ -221,7 +247,10 @@ describe('Tool: toggle_packing_item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: 99999, checked: true } });
const result = await h.client.callTool({
name: 'toggle_packing_item',
arguments: { tripId: trip.id, itemId: 99999, checked: true },
});
expect(result.isError).toBe(true);
});
});
@@ -232,7 +261,10 @@ describe('Tool: toggle_packing_item', () => {
const trip = createTrip(testDb, other.id);
const item = createPackingItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: item.id, checked: true } });
const result = await h.client.callTool({
name: 'toggle_packing_item',
arguments: { tripId: trip.id, itemId: item.id, checked: true },
});
expect(result.isError).toBe(true);
});
});
@@ -248,7 +280,10 @@ describe('Tool: delete_packing_item', () => {
const trip = createTrip(testDb, user.id);
const item = createPackingItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } });
const result = await h.client.callTool({
name: 'delete_packing_item',
arguments: { tripId: trip.id, itemId: item.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM packing_items WHERE id = ?').get(item.id)).toBeUndefined();
@@ -269,7 +304,10 @@ describe('Tool: delete_packing_item', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: 99999 } });
const result = await h.client.callTool({
name: 'delete_packing_item',
arguments: { tripId: trip.id, itemId: 99999 },
});
expect(result.isError).toBe(true);
});
});
@@ -280,7 +318,10 @@ describe('Tool: delete_packing_item', () => {
const trip = createTrip(testDb, other.id);
const item = createPackingItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } });
const result = await h.client.callTool({
name: 'delete_packing_item',
arguments: { tripId: trip.id, itemId: item.id },
});
expect(result.isError).toBe(true);
});
});
+80 -21
View File
@@ -1,6 +1,12 @@
/**
* Unit tests for MCP place tools: create_place, update_place, delete_place, list_categories, search_place.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, createPlace, createDay } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -14,13 +20,29 @@ const { testDb, dbMock } = vi.hoisted(() => {
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: number) => {
const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId);
const place: any = db
.prepare(
`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`,
)
.get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
const tags = db
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
.all(placeId);
return {
...place,
category: place.category_id
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
: null,
tags,
};
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -40,12 +62,6 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
const { searchPlacesMock } = vi.hoisted(() => ({ searchPlacesMock: vi.fn() }));
vi.mock('../../../src/services/mapsService', () => ({ searchPlaces: searchPlacesMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPlace, createDay } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -64,7 +80,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -171,7 +191,10 @@ describe('Tool: update_place', () => {
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Updated' } });
await h.client.callTool({
name: 'update_place',
arguments: { tripId: trip.id, placeId: place.id, name: 'Updated' },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:updated', expect.any(Object));
});
});
@@ -191,7 +214,10 @@ describe('Tool: update_place', () => {
const trip = createTrip(testDb, other.id);
const place = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'X' } });
const result = await h.client.callTool({
name: 'update_place',
arguments: { tripId: trip.id, placeId: place.id, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -207,7 +233,10 @@ describe('Tool: delete_place', () => {
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } });
const result = await h.client.callTool({
name: 'delete_place',
arguments: { tripId: trip.id, placeId: place.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(place.id)).toBeUndefined();
@@ -239,7 +268,10 @@ describe('Tool: delete_place', () => {
const trip = createTrip(testDb, other.id);
const place = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } });
const result = await h.client.callTool({
name: 'delete_place',
arguments: { tripId: trip.id, placeId: place.id },
});
expect(result.isError).toBe(true);
});
});
@@ -276,7 +308,13 @@ describe('Tool: search_place', () => {
searchPlacesMock.mockResolvedValue({
source: 'openstreetmap',
places: [
{ osm_id: 'node:12345', name: 'Eiffel Tower', address: 'Eiffel Tower, Paris, France', lat: 48.8584, lng: 2.2945 },
{
osm_id: 'node:12345',
name: 'Eiffel Tower',
address: 'Eiffel Tower, Paris, France',
lat: 48.8584,
lng: 2.2945,
},
],
});
@@ -296,7 +334,16 @@ describe('Tool: search_place', () => {
searchPlacesMock.mockResolvedValue({
source: 'google',
places: [
{ google_place_id: 'ChIJD3uTd9hx5kcR1IQvGfr8dbk', name: 'Eiffel Tower', address: 'Champ de Mars, Paris', lat: 48.8584, lng: 2.2945, rating: 4.7, website: 'https://toureiffel.paris', phone: null },
{
google_place_id: 'ChIJD3uTd9hx5kcR1IQvGfr8dbk',
name: 'Eiffel Tower',
address: 'Champ de Mars, Paris',
lat: 48.8584,
lng: 2.2945,
rating: 4.7,
website: 'https://toureiffel.paris',
phone: null,
},
],
});
@@ -351,7 +398,10 @@ describe('Tool: list_places', () => {
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } });
const result = await h.client.callTool({
name: 'list_places',
arguments: { tripId: trip.id, assignment: 'unassigned' },
});
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(1);
expect(data.places[0].name).toBe('Orphan Place');
@@ -367,7 +417,10 @@ describe('Tool: list_places', () => {
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'assigned' } });
const result = await h.client.callTool({
name: 'list_places',
arguments: { tripId: trip.id, assignment: 'assigned' },
});
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(1);
expect(data.places[0].name).toBe('Assigned Place');
@@ -382,7 +435,10 @@ describe('Tool: list_places', () => {
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } });
const result = await h.client.callTool({
name: 'list_places',
arguments: { tripId: trip.id, assignment: 'unassigned' },
});
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(0);
});
@@ -397,7 +453,10 @@ describe('Tool: list_places', () => {
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned', search: 'Louvre' } });
const result = await h.client.callTool({
name: 'list_places',
arguments: { tripId: trip.id, assignment: 'unassigned', search: 'Louvre' },
});
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(1);
expect(data.places[0].name).toBe('Louvre Museum');
+29 -13
View File
@@ -6,9 +6,15 @@
* with the MCP client's type-safe getPrompt. We therefore test prompt callbacks
* directly via the registered prompt handlers on the server instance.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { registerMcpPrompts } from '../../../src/mcp/tools/prompts';
import { createUser, createTrip, addTripMember, createPackingItem, createBudgetItem } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
const Database = require('better-sqlite3');
const db = new Database(':memory:');
@@ -21,7 +27,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -54,12 +64,6 @@ vi.mock('../../../src/services/tripService', () => ({
getTripSummary: mockGetTripSummary,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember, createPackingItem, createBudgetItem } from '../../helpers/factories';
import { registerMcpPrompts } from '../../../src/mcp/tools/prompts';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -76,18 +80,22 @@ beforeEach(() => {
mockGetTripSummary.mockImplementation((tripId: any) => {
const trip = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as any;
if (!trip) return null;
const members = testDb.prepare(`
const members = testDb
.prepare(
`
SELECT u.id, u.username as name, u.email
FROM trip_members m JOIN users u ON u.id = m.user_id
WHERE m.trip_id = ?
`).all(tripId) as any[];
`,
)
.all(tripId) as any[];
const budgetRows = testDb.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(tripId) as any[];
const packingRows = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(tripId) as any[];
return {
trip,
days: [],
members,
budget: budgetRows, // array shape expected by prompts.ts
budget: budgetRows, // array shape expected by prompts.ts
packing: packingRows, // array shape expected by prompts.ts
reservations: [],
collabNotes: [],
@@ -214,7 +222,15 @@ describe('Prompt: trip-summary', () => {
// Return summary with minimal trip fields (no title, no dates, no description)
mockGetTripSummary.mockReturnValueOnce({
trip: { id: trip.id, title: null, description: null, start_date: null, end_date: null, currency: null, user_id: user.id },
trip: {
id: trip.id,
title: null,
description: null,
start_date: null,
end_date: null,
currency: null,
user_id: user.id,
},
days: [],
members: [],
budget: [],
@@ -226,7 +242,7 @@ describe('Prompt: trip-summary', () => {
const server = buildServer(user.id);
const text = await invokePromptText(server, 'trip-summary', { tripId: trip.id });
expect(text).toContain('Untitled');
expect(text).toContain('?'); // start/end date fallback
expect(text).toContain('?'); // start/end date fallback
expect(text).toContain('EUR'); // currency fallback
});
});
+124 -27
View File
@@ -2,6 +2,19 @@
* Unit tests for MCP reservation tools: create_reservation, update_reservation,
* delete_reservation, link_hotel_accommodation.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
createUser,
createTrip,
createDay,
createPlace,
createReservation,
createDayAssignment,
} from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -16,7 +29,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -33,12 +50,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -56,7 +67,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -104,7 +119,9 @@ describe('Tool: create_reservation', () => {
expect(data.reservation.type).toBe('hotel');
expect(data.reservation.accommodation_id).not.toBeNull();
// accommodation was created
const acc = testDb.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(data.reservation.accommodation_id) as any;
const acc = testDb
.prepare('SELECT * FROM day_accommodations WHERE id = ?')
.get(data.reservation.accommodation_id) as any;
expect(acc.place_id).toBe(hotel.id);
expect(acc.check_in).toBe('15:00');
});
@@ -144,7 +161,10 @@ describe('Tool: create_reservation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'create_reservation', arguments: { tripId: trip.id, title: 'Bus', type: 'other' } });
await h.client.callTool({
name: 'create_reservation',
arguments: { tripId: trip.id, title: 'Bus', type: 'other' },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:created', expect.any(Object));
});
});
@@ -158,7 +178,14 @@ describe('Tool: create_reservation', () => {
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'create_reservation',
arguments: { tripId: trip.id, title: 'Hotel', type: 'hotel', place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id },
arguments: {
tripId: trip.id,
title: 'Hotel',
type: 'hotel',
place_id: hotel.id,
start_day_id: day1.id,
end_day_id: day2.id,
},
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object));
});
@@ -169,7 +196,10 @@ describe('Tool: create_reservation', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_reservation', arguments: { tripId: trip.id, title: 'X', type: 'flight' } });
const result = await h.client.callTool({
name: 'create_reservation',
arguments: { tripId: trip.id, title: 'X', type: 'flight' },
});
expect(result.isError).toBe(true);
});
});
@@ -214,7 +244,10 @@ describe('Tool: update_reservation', () => {
const trip = createTrip(testDb, user.id);
const reservation = createReservation(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_reservation', arguments: { tripId: trip.id, reservationId: reservation.id, title: 'Updated' } });
await h.client.callTool({
name: 'update_reservation',
arguments: { tripId: trip.id, reservationId: reservation.id, title: 'Updated' },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:updated', expect.any(Object));
});
});
@@ -223,7 +256,10 @@ describe('Tool: update_reservation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_reservation', arguments: { tripId: trip.id, reservationId: 99999, title: 'X' } });
const result = await h.client.callTool({
name: 'update_reservation',
arguments: { tripId: trip.id, reservationId: 99999, title: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -249,7 +285,10 @@ describe('Tool: update_reservation', () => {
const trip = createTrip(testDb, other.id);
const reservation = createReservation(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_reservation', arguments: { tripId: trip.id, reservationId: reservation.id, title: 'X' } });
const result = await h.client.callTool({
name: 'update_reservation',
arguments: { tripId: trip.id, reservationId: reservation.id, title: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -265,7 +304,10 @@ describe('Tool: delete_reservation', () => {
const trip = createTrip(testDb, user.id);
const reservation = createReservation(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } });
const result = await h.client.callTool({
name: 'delete_reservation',
arguments: { tripId: trip.id, reservationId: reservation.id },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
expect(testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id)).toBeUndefined();
@@ -283,12 +325,20 @@ describe('Tool: delete_reservation', () => {
await withHarness(user.id, async (h) => {
const r = await h.client.callTool({
name: 'create_reservation',
arguments: { tripId: trip.id, title: 'Hotel', type: 'hotel', place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id },
arguments: {
tripId: trip.id,
title: 'Hotel',
type: 'hotel',
place_id: hotel.id,
start_day_id: day1.id,
end_day_id: day2.id,
},
});
reservationId = (parseToolResult(r) as any).reservation.id;
});
const accId = (testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(reservationId!) as any).accommodation_id;
const accId = (testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(reservationId!) as any)
.accommodation_id;
expect(accId).not.toBeNull();
await withHarness(user.id, async (h) => {
@@ -303,7 +353,10 @@ describe('Tool: delete_reservation', () => {
const trip = createTrip(testDb, user.id);
const reservation = createReservation(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } });
await h.client.callTool({
name: 'delete_reservation',
arguments: { tripId: trip.id, reservationId: reservation.id },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:deleted', expect.any(Object));
});
});
@@ -312,7 +365,10 @@ describe('Tool: delete_reservation', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: 99999 } });
const result = await h.client.callTool({
name: 'delete_reservation',
arguments: { tripId: trip.id, reservationId: 99999 },
});
expect(result.isError).toBe(true);
});
});
@@ -323,7 +379,10 @@ describe('Tool: delete_reservation', () => {
const trip = createTrip(testDb, other.id);
const reservation = createReservation(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } });
const result = await h.client.callTool({
name: 'delete_reservation',
arguments: { tripId: trip.id, reservationId: reservation.id },
});
expect(result.isError).toBe(true);
});
});
@@ -345,7 +404,15 @@ describe('Tool: link_hotel_accommodation', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'link_hotel_accommodation',
arguments: { tripId: trip.id, reservationId: reservation.id, place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id, check_in: '14:00', check_out: '12:00' },
arguments: {
tripId: trip.id,
reservationId: reservation.id,
place_id: hotel.id,
start_day_id: day1.id,
end_day_id: day2.id,
check_in: '14:00',
check_out: '12:00',
},
});
const data = parseToolResult(result) as any;
expect(data.reservation.accommodation_id).not.toBeNull();
@@ -368,7 +435,13 @@ describe('Tool: link_hotel_accommodation', () => {
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'link_hotel_accommodation',
arguments: { tripId: trip.id, reservationId: reservation.id, place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id },
arguments: {
tripId: trip.id,
reservationId: reservation.id,
place_id: hotel.id,
start_day_id: day1.id,
end_day_id: day2.id,
},
});
});
@@ -376,7 +449,13 @@ describe('Tool: link_hotel_accommodation', () => {
await withHarness(user.id, async (h) => {
await h.client.callTool({
name: 'link_hotel_accommodation',
arguments: { tripId: trip.id, reservationId: reservation.id, place_id: hotel2.id, start_day_id: day2.id, end_day_id: day3.id },
arguments: {
tripId: trip.id,
reservationId: reservation.id,
place_id: hotel2.id,
start_day_id: day2.id,
end_day_id: day3.id,
},
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:updated', expect.any(Object));
});
@@ -392,7 +471,13 @@ describe('Tool: link_hotel_accommodation', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'link_hotel_accommodation',
arguments: { tripId: trip.id, reservationId: reservation.id, place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
arguments: {
tripId: trip.id,
reservationId: reservation.id,
place_id: place.id,
start_day_id: day1.id,
end_day_id: day2.id,
},
});
expect(result.isError).toBe(true);
});
@@ -409,7 +494,13 @@ describe('Tool: link_hotel_accommodation', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'link_hotel_accommodation',
arguments: { tripId: trip1.id, reservationId: reservation.id, place_id: placeFromTrip2.id, start_day_id: day1.id, end_day_id: day2.id },
arguments: {
tripId: trip1.id,
reservationId: reservation.id,
place_id: placeFromTrip2.id,
start_day_id: day1.id,
end_day_id: day2.id,
},
});
expect(result.isError).toBe(true);
});
@@ -426,7 +517,13 @@ describe('Tool: link_hotel_accommodation', () => {
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'link_hotel_accommodation',
arguments: { tripId: trip.id, reservationId: reservation.id, place_id: place.id, start_day_id: day1.id, end_day_id: day2.id },
arguments: {
tripId: trip.id,
reservationId: reservation.id,
place_id: place.id,
start_day_id: day1.id,
end_day_id: day2.id,
},
});
expect(result.isError).toBe(true);
});
@@ -4,6 +4,13 @@
* get_place_details, reverse_geocode, resolve_maps_url,
* get_weather, get_detailed_weather.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import * as mapsService from '../../../src/services/mapsService';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -18,7 +25,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -47,13 +58,6 @@ vi.mock('../../../src/services/weatherService', () => ({
getDetailedWeather: vi.fn().mockResolvedValue({ hourly: [] }),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import * as mapsService from '../../../src/services/mapsService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -71,7 +75,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -154,7 +162,9 @@ describe('Tool: create_tag', () => {
describe('Tool: update_tag', () => {
it('updates tag name and color', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Old Name', '#aaaaaa');
const r = testDb
.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)')
.run(user.id, 'Old Name', '#aaaaaa');
const tagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -187,7 +197,9 @@ describe('Tool: update_tag', () => {
describe('Tool: delete_tag', () => {
it('removes the tag row', async () => {
const { user } = createUser(testDb);
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'To Delete', '#cccccc');
const r = testDb
.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)')
.run(user.id, 'To Delete', '#cccccc');
const tagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
+47 -16
View File
@@ -3,6 +3,12 @@
* create_todo, update_todo, toggle_todo, delete_todo, reorder_todos,
* list_todos, get_todo_category_assignees, set_todo_category_assignees.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, createTodoItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -17,7 +23,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -34,12 +44,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createTodoItem } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -57,7 +61,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -195,8 +203,14 @@ describe('Tool: update_todo', () => {
it('clears due_date when passed null', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare("INSERT INTO todo_items (trip_id, name, checked, sort_order, due_date) VALUES (?, 'Task', 0, 0, '2025-01-01')").run(trip.id);
const item = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as any;
testDb
.prepare(
"INSERT INTO todo_items (trip_id, name, checked, sort_order, due_date) VALUES (?, 'Task', 0, 0, '2025-01-01')",
)
.run(trip.id);
const item = testDb
.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY id DESC LIMIT 1')
.get(trip.id) as any;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_todo',
@@ -212,7 +226,10 @@ describe('Tool: update_todo', () => {
const trip = createTrip(testDb, user.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } });
await h.client.callTool({
name: 'update_todo',
arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' },
});
expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object));
});
});
@@ -221,7 +238,10 @@ describe('Tool: update_todo', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } });
const result = await h.client.callTool({
name: 'update_todo',
arguments: { tripId: trip.id, itemId: 99999, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -232,7 +252,10 @@ describe('Tool: update_todo', () => {
const trip = createTrip(testDb, other.id);
const item = createTodoItem(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } });
const result = await h.client.callTool({
name: 'update_todo',
arguments: { tripId: trip.id, itemId: item.id, name: 'X' },
});
expect(result.isError).toBe(true);
});
});
@@ -285,7 +308,10 @@ describe('Tool: toggle_todo', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'toggle_todo', arguments: { tripId: trip.id, itemId: 99999, checked: true } });
const result = await h.client.callTool({
name: 'toggle_todo',
arguments: { tripId: trip.id, itemId: 99999, checked: true },
});
expect(result.isError).toBe(true);
});
});
@@ -367,7 +393,10 @@ describe('Tool: reorder_todos', () => {
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'reorder_todos', arguments: { tripId: trip.id, orderedIds: [1] } });
const result = await h.client.callTool({
name: 'reorder_todos',
arguments: { tripId: trip.id, orderedIds: [1] },
});
expect(result.isError).toBe(true);
});
});
@@ -412,7 +441,9 @@ describe('Tool: set_todo_category_assignees', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Set then clear
testDb.prepare('INSERT INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Booking', user.id);
testDb
.prepare('INSERT INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)')
.run(trip.id, 'Booking', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'set_todo_category_assignees',
@@ -3,6 +3,12 @@
* list_trip_members, add_trip_member, remove_trip_member,
* copy_trip, export_trip_ics, get_share_link, create_share_link, delete_share_link.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -17,7 +23,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -34,12 +44,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -57,7 +61,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -179,7 +187,9 @@ describe('Tool: remove_trip_member', () => {
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
const row = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id);
const row = testDb
.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?')
.get(trip.id, member.id);
expect(row).toBeUndefined();
});
});
@@ -306,9 +316,11 @@ describe('Tool: get_share_link', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
// Create a share link directly
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'test-token-123', user.id);
testDb
.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)',
)
.run(trip.id, 'test-token-123', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
@@ -336,9 +348,11 @@ describe('Tool: create_share_link', () => {
it('updates existing share link permissions', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'existing-token', user.id);
testDb
.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)',
)
.run(trip.id, 'existing-token', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'create_share_link',
@@ -364,9 +378,11 @@ describe('Tool: delete_share_link', () => {
it('revokes the share link', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)'
).run(trip.id, 'to-delete', user.id);
testDb
.prepare(
'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)',
)
.run(trip.id, 'to-delete', user.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
+87 -42
View File
@@ -1,6 +1,25 @@
/**
* Unit tests for MCP trip tools: create_trip, update_trip, delete_trip, list_trips, get_trip_summary.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
createUser,
createTrip,
createDay,
createPlace,
addTripMember,
createBudgetItem,
createPackingItem,
createReservation,
createDayNote,
createCollabNote,
createDayAssignment,
createDayAccommodation,
} from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -15,7 +34,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -32,12 +55,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -55,7 +72,11 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -70,7 +91,9 @@ describe('Tool: create_trip', () => {
const data = parseToolResult(result) as any;
expect(data.trip).toBeTruthy();
expect(data.trip.title).toBe('Summer Escape');
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as {
c: number;
};
expect(days.c).toBe(7);
});
});
@@ -83,7 +106,9 @@ describe('Tool: create_trip', () => {
arguments: { title: 'Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' },
});
const data = parseToolResult(result) as any;
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as {
c: number;
};
expect(days.c).toBe(5);
});
});
@@ -96,7 +121,9 @@ describe('Tool: create_trip', () => {
arguments: { title: 'Long Trip', start_date: '2026-01-01', end_date: '2027-12-31' },
});
const data = parseToolResult(result) as any;
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number };
const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as {
c: number;
};
expect(days.c).toBe(90);
});
});
@@ -104,7 +131,10 @@ describe('Tool: create_trip', () => {
it('returns error for invalid start_date format', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Trip', start_date: 'not-a-date' } });
const result = await h.client.callTool({
name: 'create_trip',
arguments: { title: 'Trip', start_date: 'not-a-date' },
});
expect(result.isError).toBe(true);
});
});
@@ -139,7 +169,10 @@ describe('Tool: update_trip', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Old Title' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, title: 'New Title' } });
const result = await h.client.callTool({
name: 'update_trip',
arguments: { tripId: trip.id, title: 'New Title' },
});
const data = parseToolResult(result) as any;
expect(data.trip.title).toBe('New Title');
});
@@ -193,11 +226,15 @@ describe('Tool: update_trip', () => {
const planRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
const planId = Number(planRes.lastInsertRowid);
testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(planId, 2026);
testDb.prepare(
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
).run(user.id, planId, 2026);
testDb
.prepare(
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)',
)
.run(user.id, planId, 2026);
for (const d of ['2026-08-03', '2026-08-04', '2026-08-05', '2026-08-06', '2026-08-07']) {
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(planId, user.id, d, '');
testDb
.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
.run(planId, user.id, d, '');
}
await withHarness(user.id, async (h) => {
@@ -210,21 +247,19 @@ describe('Tool: update_trip', () => {
expect(data.trip.end_date).toBe('2026-08-16');
});
const oldWindow = testDb.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-01' AND '2026-08-09'"
).all(planId, user.id) as { date: string }[];
const oldWindow = testDb
.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-01' AND '2026-08-09'",
)
.all(planId, user.id) as { date: string }[];
expect(oldWindow).toHaveLength(0);
const shifted = testDb.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-08' AND '2026-08-16' ORDER BY date"
).all(planId, user.id) as { date: string }[];
expect(shifted.map(r => r.date)).toEqual([
'2026-08-10',
'2026-08-11',
'2026-08-12',
'2026-08-13',
'2026-08-14',
]);
const shifted = testDb
.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-08-08' AND '2026-08-16' ORDER BY date",
)
.all(planId, user.id) as { date: string }[];
expect(shifted.map((r) => r.date)).toEqual(['2026-08-10', '2026-08-11', '2026-08-12', '2026-08-13', '2026-08-14']);
});
it('shifts entries from the owners own plan even if another vacay plan is active', async () => {
@@ -236,17 +271,23 @@ describe('Tool: update_trip', () => {
const ownPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(user.id);
const ownPlanId = Number(ownPlanRes.lastInsertRowid);
testDb.prepare('INSERT INTO vacay_years (plan_id, year) VALUES (?, ?)').run(ownPlanId, 2026);
testDb.prepare(
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)'
).run(user.id, ownPlanId, 2026);
testDb
.prepare(
'INSERT INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over) VALUES (?, ?, ?, 30, 0)',
)
.run(user.id, ownPlanId, 2026);
for (const d of ['2026-09-02', '2026-09-03']) {
testDb.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(ownPlanId, user.id, d, '');
testDb
.prepare('INSERT INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
.run(ownPlanId, user.id, d, '');
}
// Different accepted plan becomes "active" for the owner.
const foreignPlanRes = testDb.prepare('INSERT INTO vacay_plans (owner_id) VALUES (?)').run(otherOwner.id);
const foreignPlanId = Number(foreignPlanRes.lastInsertRowid);
testDb.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)').run(foreignPlanId, user.id, 'accepted');
testDb
.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)')
.run(foreignPlanId, user.id, 'accepted');
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
@@ -256,15 +297,19 @@ describe('Tool: update_trip', () => {
expect(result.isError).toBeFalsy();
});
const oldWindow = testDb.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-01' AND '2026-09-07' ORDER BY date"
).all(ownPlanId, user.id) as { date: string }[];
const oldWindow = testDb
.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-01' AND '2026-09-07' ORDER BY date",
)
.all(ownPlanId, user.id) as { date: string }[];
expect(oldWindow).toHaveLength(0);
const shifted = testDb.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-08' AND '2026-09-14' ORDER BY date"
).all(ownPlanId, user.id) as { date: string }[];
expect(shifted.map(r => r.date)).toEqual(['2026-09-09', '2026-09-10']);
const shifted = testDb
.prepare(
"SELECT date FROM vacay_entries WHERE plan_id = ? AND user_id = ? AND date BETWEEN '2026-09-08' AND '2026-09-14' ORDER BY date",
)
.all(ownPlanId, user.id) as { date: string }[];
expect(shifted.map((r) => r.date)).toEqual(['2026-09-09', '2026-09-10']);
});
});
+34 -13
View File
@@ -8,6 +8,12 @@
* list_holiday_countries, list_holidays.
* Resources: trek://vacay/plan, trek://vacay/entries/{year}.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -22,7 +28,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -46,7 +56,7 @@ vi.mock('../../../src/services/adminService', () => ({
// Mock async service functions that make external calls
vi.mock('../../../src/services/vacayService', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
const original = (await importOriginal()) as Record<string, unknown>;
return {
...original,
updatePlan: vi.fn().mockResolvedValue(undefined),
@@ -55,12 +65,6 @@ vi.mock('../../../src/services/vacayService', async (importOriginal) => {
};
});
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -78,12 +82,20 @@ afterAll(() => {
async function withHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: false });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise<void>) {
const h = await createMcpHarness({ userId, withResources: true });
try { await fn(h); } finally { await h.cleanup(); }
try {
await fn(h);
} finally {
await h.cleanup();
}
}
// ---------------------------------------------------------------------------
@@ -309,7 +321,10 @@ describe('Tool: update_vacay_stats', () => {
it('updates vacation days allowance and returns success', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 25 } });
const result = await h.client.callTool({
name: 'update_vacay_stats',
arguments: { year: 2025, vacationDays: 25 },
});
const data = parseToolResult(result) as any;
expect(data.success).toBe(true);
});
@@ -319,7 +334,10 @@ describe('Tool: update_vacay_stats', () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 20 } });
const result = await h.client.callTool({
name: 'update_vacay_stats',
arguments: { year: 2025, vacationDays: 20 },
});
expect(result.isError).toBe(true);
});
});
@@ -383,7 +401,10 @@ describe('Tool: update_holiday_calendar', () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'update_holiday_calendar', arguments: { calendarId: 1, label: 'X' } });
const result = await h.client.callTool({
name: 'update_holiday_calendar',
arguments: { calendarId: 1, label: 'X' },
});
expect(result.isError).toBe(true);
});
});
+14 -14
View File
@@ -1,19 +1,21 @@
import { describe, it, expect, vi, afterEach } from 'vitest';
import { db } from '../../../src/db/database';
import { extractToken, authenticate, adminOnly } from '../../../src/middleware/auth';
import type { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { describe, it, expect, vi, afterEach } from 'vitest';
vi.mock('../../../src/db/database', () => ({
db: { prepare: vi.fn(() => ({ get: vi.fn(), all: vi.fn() })) },
}));
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
import { extractToken, authenticate, adminOnly } from '../../../src/middleware/auth';
import { db } from '../../../src/db/database';
import type { Request, Response, NextFunction } from 'express';
function makeReq(overrides: {
cookies?: Record<string, string>;
headers?: Record<string, string>;
} = {}): Request {
function makeReq(
overrides: {
cookies?: Record<string, string>;
headers?: Record<string, string>;
} = {},
): Request {
return {
cookies: overrides.cookies || {},
headers: overrides.headers || {},
@@ -114,11 +116,9 @@ describe('authenticate', () => {
});
it('AUTH-MW-005: returns 401 for an expired JWT', () => {
const expiredToken = jwt.sign(
{ id: 1, exp: Math.floor(Date.now() / 1000) - 3600 },
'test-secret',
{ algorithm: 'HS256' }
);
const expiredToken = jwt.sign({ id: 1, exp: Math.floor(Date.now() / 1000) - 3600 }, 'test-secret', {
algorithm: 'HS256',
});
const next = vi.fn() as unknown as NextFunction;
const { res, status } = makeRes();
authenticate(makeReq({ cookies: { trek_session: expiredToken } }), res, next);
@@ -1,3 +1,6 @@
import { applyIdempotency } from '../../../src/middleware/idempotency';
import type { Request, Response, NextFunction } from 'express';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── In-memory store + DB mock using vi.hoisted ────────────────────────────────
@@ -12,7 +15,14 @@ const { rows, dbMock } = vi.hoisted(() => {
return rows[`${key}:${userId}:${method}:${path}`] ?? undefined;
}),
run: vi.fn((...args: unknown[]) => {
const [key, userId, method, path, status_code, response_body] = args as [string, number, string, string, number, string];
const [key, userId, method, path, status_code, response_body] = args as [
string,
number,
string,
string,
number,
string,
];
const k = `${key}:${userId}:${method}:${path}`;
if (!rows[k]) rows[k] = { status_code, response_body };
}),
@@ -25,9 +35,6 @@ const { rows, dbMock } = vi.hoisted(() => {
vi.mock('../../../src/db/database', () => dbMock);
import { applyIdempotency } from '../../../src/middleware/idempotency';
import type { Request, Response, NextFunction } from 'express';
function makeReq(method = 'POST', headers: Record<string, string> = {}, path = '/api/test'): Request {
return { method, path, headers } as unknown as Request;
}
@@ -35,15 +42,20 @@ function makeReq(method = 'POST', headers: Record<string, string> = {}, path = '
function makeRes(statusCode = 200): Response {
const ctx = { status: statusCode };
const res = {
get statusCode() { return ctx.status; },
status(code: number) { ctx.status = code; return res; },
get statusCode() {
return ctx.status;
},
status(code: number) {
ctx.status = code;
return res;
},
json: vi.fn((_body: unknown) => res),
} as unknown as Response;
return res;
}
beforeEach(() => {
Object.keys(rows).forEach(k => delete rows[k]);
Object.keys(rows).forEach((k) => delete rows[k]);
vi.clearAllMocks();
});
@@ -1,3 +1,5 @@
import { isPublicApiPath, isMfaSetupExemptPath } from '../../../src/middleware/mfaPolicy';
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../../src/db/database', () => ({
@@ -5,8 +7,6 @@ vi.mock('../../../src/db/database', () => ({
}));
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
import { isPublicApiPath, isMfaSetupExemptPath } from '../../../src/middleware/mfaPolicy';
// ── isPublicApiPath ──────────────────────────────────────────────────────────
describe('isPublicApiPath', () => {
@@ -3,8 +3,10 @@
* TRIP-ACCESS-001 through TRIP-ACCESS-010.
* canAccessTrip and isOwner are mocked; no DB required.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { requireTripAccess, requireTripOwner } from '../../../src/middleware/tripAccess';
import type { Request, Response, NextFunction } from 'express';
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockCanAccessTrip = vi.fn();
const mockIsOwner = vi.fn();
@@ -15,8 +17,6 @@ vi.mock('../../../src/db/database', () => ({
}));
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret' }));
import { requireTripAccess, requireTripOwner } from '../../../src/middleware/tripAccess';
function makeRes(): { res: Response; status: ReturnType<typeof vi.fn>; json: ReturnType<typeof vi.fn> } {
const json = vi.fn();
const status = vi.fn(() => ({ json }));
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { maxLength, validateStringLengths } from '../../../src/middleware/validate';
import type { Request, Response, NextFunction } from 'express';
import { describe, it, expect, vi } from 'vitest';
function makeReq(body: Record<string, unknown> = {}): Request {
return { body } as Request;
+3 -2
View File
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { HttpException } from '@nestjs/common';
import { JwtAuthGuard } from '../../../src/nest/auth/jwt-auth.guard';
import { HttpException } from '@nestjs/common';
import { describe, it, expect } from 'vitest';
function context(req: unknown) {
return { switchToHttp: () => ({ getRequest: () => req }) } as never;
@@ -2,9 +2,10 @@
* DatabaseService the shared better-sqlite3 provider (F3). Exercises every
* helper against the real connection so the typed query surface is covered.
*/
import { describe, it, expect } from 'vitest';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { describe, it, expect } from 'vitest';
describe('DatabaseService (typed query helpers)', () => {
const svc = new DatabaseService();
@@ -1,6 +1,7 @@
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { TrekExceptionFilter } from '../../../src/nest/common/trek-exception.filter';
import { HttpException } from '@nestjs/common';
import { describe, it, expect, vi } from 'vitest';
function mockHost() {
const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis() };
+5 -7
View File
@@ -1,17 +1,15 @@
import { describe, it, expect } from 'vitest';
import { Test } from '@nestjs/testing';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { HealthController } from '../../../src/nest/health/health.controller';
import { HealthService } from '../../../src/nest/health/health.service';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { Test } from '@nestjs/testing';
import { describe, it, expect } from 'vitest';
describe('Nest dependency injection (vitest + swc)', () => {
it('injects HealthService + DatabaseService into HealthController by type', async () => {
const moduleRef = await Test.createTestingModule({
controllers: [HealthController],
providers: [
HealthService,
{ provide: DatabaseService, useValue: { get: () => ({ n: 7 }) } },
],
providers: [HealthService, { provide: DatabaseService, useValue: { get: () => ({ n: 7 }) } }],
}).compile();
const controller = moduleRef.get(HealthController);
+2 -1
View File
@@ -1,6 +1,7 @@
import { describe, it, expect, afterEach } from 'vitest';
import { getNestPrefixes, makeNestPathMatcher } from '../../../src/nest/strangler';
import { describe, it, expect, afterEach } from 'vitest';
describe('strangler toggle', () => {
const original = process.env.NEST_PREFIXES;
afterEach(() => {
@@ -1,8 +1,9 @@
import { describe, it, expect, vi } from 'vitest';
import { HttpException } from '@nestjs/common';
import { WeatherController } from '../../../src/nest/weather/weather.controller';
import { ApiError } from '../../../src/services/weatherService';
import type { WeatherService } from '../../../src/nest/weather/weather.service';
import { ApiError } from '../../../src/services/weatherService';
import { HttpException } from '@nestjs/common';
import { describe, it, expect, vi } from 'vitest';
function makeController(svc: Partial<WeatherService>) {
return new WeatherController(svc as WeatherService);
+6 -5
View File
@@ -1,10 +1,11 @@
import { describe, it, expect } from 'vitest';
import { AppModule } from '../../../src/nest/app.module';
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { HealthController } from '../../../src/nest/health/health.controller';
import { HttpException } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { AppModule } from '../../../src/nest/app.module';
import { HealthController } from '../../../src/nest/health/health.controller';
import { DatabaseService } from '../../../src/nest/database/database.service';
import { AdminGuard } from '../../../src/nest/auth/admin.guard';
import { describe, it, expect } from 'vitest';
function ctx(user: unknown) {
return { switchToHttp: () => ({ getRequest: () => ({ user }) }) } as never;
+3 -2
View File
@@ -1,7 +1,8 @@
import { ZodValidationPipe } from '../../../src/nest/common/zod-validation.pipe';
import { HttpException } from '@nestjs/common';
import { describe, it, expect } from 'vitest';
import { z } from 'zod';
import { HttpException } from '@nestjs/common';
import { ZodValidationPipe } from '../../../src/nest/common/zod-validation.pipe';
describe('ZodValidationPipe', () => {
const pipe = new ZodValidationPipe(z.object({ name: z.string().min(1) }));
+38 -12
View File
@@ -1,3 +1,6 @@
import { buildCronExpression, cleanupOldBackups } from '../../src/scheduler';
import fs from 'node:fs';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Prevent node-cron from scheduling anything at import time
@@ -38,9 +41,6 @@ vi.mock('../../src/services/auditLog', () => ({
logError: vi.fn(),
}));
import fs from 'node:fs';
import { buildCronExpression, cleanupOldBackups } from '../../src/scheduler';
interface BackupSettings {
enabled: boolean;
interval: string;
@@ -65,7 +65,9 @@ function settings(overrides: Partial<BackupSettings> = {}): BackupSettings {
describe('buildCronExpression', () => {
describe('hourly', () => {
it('returns 0 * * * * regardless of hour/dow/dom', () => {
expect(buildCronExpression(settings({ interval: 'hourly', hour: 5, day_of_week: 3, day_of_month: 15 }))).toBe('0 * * * *');
expect(buildCronExpression(settings({ interval: 'hourly', hour: 5, day_of_week: 3, day_of_month: 15 }))).toBe(
'0 * * * *',
);
});
});
@@ -152,7 +154,12 @@ describe('cleanupOldBackups', () => {
vi.mocked(fs.readdirSync).mockReset();
vi.mocked(fs.statSync).mockReset();
vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>).mockReset();
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ mtime: new Date(), mtimeMs: NOW, birthtimeMs: NOW, size: 0 });
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({
mtime: new Date(),
mtimeMs: NOW,
birthtimeMs: NOW,
size: 0,
});
});
it('never deletes manual backup-*.zip files regardless of age', () => {
@@ -160,7 +167,9 @@ describe('cleanupOldBackups', () => {
const auto = isoFilename(0);
vi.mocked(fs.readdirSync).mockReturnValue([manual, auto] as unknown as string[]);
cleanupOldBackups(7, NOW);
const deleted = (vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).mock.calls.map((c: unknown[]) => c[0] as string);
const deleted = vi
.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)
.mock.calls.map((c: unknown[]) => c[0] as string);
expect(deleted.some((p: string) => p.includes(manual))).toBe(false);
});
@@ -176,28 +185,43 @@ describe('cleanupOldBackups', () => {
vi.mocked(fs.readdirSync).mockReturnValue([old] as unknown as string[]);
cleanupOldBackups(7, NOW);
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).toHaveBeenCalledOnce();
const [calledPath] = (vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).mock.calls[0] as string[];
const [calledPath] = vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>).mock.calls[0] as string[];
expect(calledPath).toContain(old);
});
it('overlayfs regression: birthtimeMs=0 does not delete a same-day backup', () => {
const fresh = isoFilename(0);
vi.mocked(fs.readdirSync).mockReturnValue([fresh] as unknown as string[]);
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW, mtime: new Date(NOW), size: 100 });
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({
birthtimeMs: 0,
mtimeMs: NOW,
mtime: new Date(NOW),
size: 100,
});
cleanupOldBackups(7, NOW);
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
});
it('malformed filename falls back to mtimeMs: keeps recent file', () => {
vi.mocked(fs.readdirSync).mockReturnValue(['auto-backup-garbage.zip'] as unknown as string[]);
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW - 1 * DAY, mtime: new Date(NOW - 1 * DAY), size: 0 });
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({
birthtimeMs: 0,
mtimeMs: NOW - 1 * DAY,
mtime: new Date(NOW - 1 * DAY),
size: 0,
});
cleanupOldBackups(7, NOW);
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).not.toHaveBeenCalled();
});
it('malformed filename falls back to mtimeMs: deletes stale file', () => {
vi.mocked(fs.readdirSync).mockReturnValue(['auto-backup-garbage.zip'] as unknown as string[]);
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({ birthtimeMs: 0, mtimeMs: NOW - 30 * DAY, mtime: new Date(NOW - 30 * DAY), size: 0 });
(vi.mocked(fs.statSync) as ReturnType<typeof vi.fn>).mockReturnValue({
birthtimeMs: 0,
mtimeMs: NOW - 30 * DAY,
mtime: new Date(NOW - 30 * DAY),
size: 0,
});
cleanupOldBackups(7, NOW);
expect(vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).toHaveBeenCalledOnce();
});
@@ -206,13 +230,15 @@ describe('cleanupOldBackups', () => {
const old = isoFilename(30);
vi.mocked(fs.readdirSync).mockReturnValue([old, 'notes.txt'] as unknown as string[]);
cleanupOldBackups(7, NOW);
const calls = (vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>)).mock.calls as string[][];
const calls = vi.mocked(fs.unlinkSync as ReturnType<typeof vi.fn>).mock.calls as string[][];
expect(calls.every(([p]: string[]) => !p.includes('notes.txt'))).toBe(true);
expect(calls.length).toBe(1);
});
it('swallows readdirSync errors without throwing', () => {
vi.mocked(fs.readdirSync).mockImplementation(() => { throw new Error('ENOENT'); });
vi.mocked(fs.readdirSync).mockImplementation(() => {
throw new Error('ENOENT');
});
expect(() => cleanupOldBackups(7, NOW)).not.toThrow();
});
});
+68 -57
View File
@@ -3,6 +3,46 @@
* Uses a real in-memory SQLite DB. Focuses on validation/error branches
* that the integration tests don't exercise.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
listUsers,
createUser as svcCreateUser,
updateUser,
deleteUser,
getStats,
getPermissions,
savePermissions,
getAuditLog,
listInvites,
createInvite,
deleteInvite,
getBagTracking,
updateBagTracking,
listPackingTemplates,
createPackingTemplate,
updatePackingTemplate,
deletePackingTemplate,
createTemplateCategory,
updateTemplateCategory,
deleteTemplateCategory,
getPackingTemplate,
createTemplateItem,
updateTemplateItem,
deleteTemplateItem,
getOidcSettings,
updateOidcSettings,
saveDemoBaseline,
getGithubReleases,
checkVersion,
listAddons,
updateAddon,
listMcpTokens,
deleteMcpToken,
} from '../../../src/services/adminService';
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -42,46 +82,6 @@ vi.mock('../../../src/demo/demo-reset', () => ({
saveBaseline: vi.fn(),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
import {
listUsers,
createUser as svcCreateUser,
updateUser,
deleteUser,
getStats,
getPermissions,
savePermissions,
getAuditLog,
listInvites,
createInvite,
deleteInvite,
getBagTracking,
updateBagTracking,
listPackingTemplates,
createPackingTemplate,
updatePackingTemplate,
deletePackingTemplate,
createTemplateCategory,
updateTemplateCategory,
deleteTemplateCategory,
getPackingTemplate,
createTemplateItem,
updateTemplateItem,
deleteTemplateItem,
getOidcSettings,
updateOidcSettings,
saveDemoBaseline,
getGithubReleases,
checkVersion,
listAddons,
updateAddon,
listMcpTokens,
deleteMcpToken,
} from '../../../src/services/adminService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -122,7 +122,12 @@ describe('createUser (service)', () => {
});
it('ADMIN-SVC-004 — returns 400 for invalid role', () => {
const result = svcCreateUser({ username: 'u1', email: 'u1@test.com', password: 'ValidPass1!', role: 'superuser' }) as any;
const result = svcCreateUser({
username: 'u1',
email: 'u1@test.com',
password: 'ValidPass1!',
role: 'superuser',
}) as any;
expect(result.status).toBe(400);
expect(result.error).toMatch(/invalid role/i);
});
@@ -429,9 +434,9 @@ describe('Template categories', () => {
describe('getAuditLog — JSON details', () => {
it('ADMIN-SVC-045 — parses JSON details when present', () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
user.id, 'test_action', JSON.stringify({ key: 'val' })
);
testDb
.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
.run(user.id, 'test_action', JSON.stringify({ key: 'val' }));
const result = getAuditLog({}) as any;
expect(result.entries.length).toBeGreaterThanOrEqual(1);
const entry = result.entries.find((e: any) => e.action === 'test_action');
@@ -441,9 +446,9 @@ describe('getAuditLog — JSON details', () => {
it('ADMIN-SVC-046 — handles invalid JSON gracefully with _parse_error flag', () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)').run(
user.id, 'bad_json_action', 'not-valid-json{'
);
testDb
.prepare('INSERT INTO audit_log (user_id, action, details) VALUES (?, ?, ?)')
.run(user.id, 'bad_json_action', 'not-valid-json{');
const result = getAuditLog({}) as any;
const entry = result.entries.find((e: any) => e.action === 'bad_json_action');
expect(entry).toBeDefined();
@@ -526,10 +531,13 @@ describe('getGithubReleases', () => {
{ id: 1, tag_name: 'v3.0.0', name: 'Release 3.0.0', html_url: 'https://github.com/example/releases/tag/v3.0.0' },
{ id: 2, tag_name: 'v2.9.9', name: 'Release 2.9.9', html_url: 'https://github.com/example/releases/tag/v2.9.9' },
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => mockReleases,
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => mockReleases,
}),
);
const result = await getGithubReleases();
expect(Array.isArray(result)).toBe(true);
expect(result).toHaveLength(2);
@@ -546,18 +554,21 @@ describe('checkVersion', () => {
it('ADMIN-SVC-054 — returns update_available:false when fetch fails', async () => {
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('Network error')));
const result = await checkVersion() as any;
const result = (await checkVersion()) as any;
expect(result.update_available).toBe(false);
expect(result.current).toBeDefined();
expect(result.latest).toBeDefined();
});
it('ADMIN-SVC-055 — returns update_available:true when latest version is greater than current', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: 'v999.0.0', html_url: 'https://github.com/example/releases/tag/v999.0.0' }),
}));
const result = await checkVersion() as any;
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ tag_name: 'v999.0.0', html_url: 'https://github.com/example/releases/tag/v999.0.0' }),
}),
);
const result = (await checkVersion()) as any;
expect(result.update_available).toBe(true);
expect(result.latest).toBe('999.0.0');
expect(result.release_url).toBe('https://github.com/example/releases/tag/v999.0.0');
@@ -1,3 +1,5 @@
import { encrypt_api_key, decrypt_api_key, maybe_encrypt_api_key } from '../../../src/services/apiKeyCrypto';
import { describe, it, expect, vi } from 'vitest';
// Inline factory to avoid vi.mock hoisting issue (no imported vars allowed)
@@ -7,8 +9,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
import { encrypt_api_key, decrypt_api_key, maybe_encrypt_api_key } from '../../../src/services/apiKeyCrypto';
describe('apiKeyCrypto', () => {
const PLAINTEXT_KEY = 'my-secret-api-key-12345';
const ENC_PREFIX = 'enc:v1:';
+93 -44
View File
@@ -1,3 +1,19 @@
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
getStats,
getCached,
setCache,
getCountryFromCoords,
getCountryFromAddress,
reverseGeocodeCountry,
getRegionGeo,
getCountryPlaces,
getVisitedRegions,
} from '../../../src/services/atlasService';
import { createUser, createTrip } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup (real in-memory SQLite — same pattern as mcp unit tests) ────────
@@ -14,11 +30,15 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
db
.prepare(
`
SELECT t.id, t.user_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),
`,
)
.get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
@@ -27,17 +47,11 @@ const { testDb, dbMock } = vi.hoisted(() => {
vi.mock('../../../src/db/database', () => dbMock);
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 { getStats, getCached, setCache, getCountryFromCoords, getCountryFromAddress, reverseGeocodeCountry, getRegionGeo, getCountryPlaces, getVisitedRegions } from '../../../src/services/atlasService';
function insertPlace(db: any, tripId: number, name: string, address: string | null = null) {
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
const result = db.prepare(
'INSERT INTO places (trip_id, name, address, category_id) VALUES (?, ?, ?, ?)'
).run(tripId, name, address, cat?.id ?? null);
const result = db
.prepare('INSERT INTO places (trip_id, name, address, category_id) VALUES (?, ?, ?, ?)')
.run(tripId, name, address, cat?.id ?? null);
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
}
@@ -49,10 +63,13 @@ beforeAll(() => {
beforeEach(() => {
resetTestDb(testDb);
// Stub fetch so reverseGeocodeCountry never makes real HTTP calls
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
json: async () => ({}),
}),
);
});
afterAll(() => {
@@ -208,12 +225,15 @@ describe('reverseGeocodeCountry', () => {
});
it('ATLAS-SVC-014: returns country code when Nominatim returns valid response', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ address: { country_code: 'fr' } }),
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ address: { country_code: 'fr' } }),
}),
);
// Berlin-ish coords not used elsewhere — unique to avoid cache collision
const code = await reverseGeocodeCountry(52.52, 13.40);
const code = await reverseGeocodeCountry(52.52, 13.4);
expect(code).toBe('FR');
});
@@ -264,10 +284,13 @@ describe('getRegionGeo', () => {
{ type: 'Feature', properties: { iso_a2: 'FR' }, geometry: {} },
],
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => mockGeoJson,
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => mockGeoJson,
}),
);
// Pass lowercase 'de' — getRegionGeo uppercases internally for matching
const result = await getRegionGeo(['de']);
@@ -280,11 +303,18 @@ describe('getRegionGeo', () => {
// ── Helpers for new tests ────────────────────────────────────────────────────
function insertPlaceWithCoords(db: any, tripId: number, name: string, lat: number, lng: number, address: string | null = null) {
function insertPlaceWithCoords(
db: any,
tripId: number,
name: string,
lat: number,
lng: number,
address: string | null = null,
) {
const cat = db.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number } | undefined;
const result = db.prepare(
'INSERT INTO places (trip_id, name, address, lat, lng, category_id) VALUES (?, ?, ?, ?, ?, ?)'
).run(tripId, name, address, lat, lng, cat?.id ?? null);
const result = db
.prepare('INSERT INTO places (trip_id, name, address, lat, lng, category_id) VALUES (?, ?, ?, ?, ?, ?)')
.run(tripId, name, address, lat, lng, cat?.id ?? null);
return db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid);
}
@@ -325,7 +355,11 @@ describe('getStats — extended', () => {
it('ATLAS-UNIT-008: lastTrip is resolved with a country code when its places have an address', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Past France Trip', start_date: '2023-05-01', end_date: '2023-05-10' });
const trip = createTrip(testDb, user.id, {
title: 'Past France Trip',
start_date: '2023-05-01',
end_date: '2023-05-10',
});
insertPlace(testDb, trip.id, 'Eiffel Tower', 'Champ de Mars, Paris, France');
const stats = await getStats(user.id);
@@ -350,8 +384,16 @@ describe('getStats — extended', () => {
it('ATLAS-UNIT-010: streak counts consecutive years with trips and firstYear is the earliest', async () => {
const { user } = createUser(testDb);
const currentYear = new Date().getFullYear();
createTrip(testDb, user.id, { title: 'This Year', start_date: `${currentYear}-06-01`, end_date: `${currentYear}-06-10` });
createTrip(testDb, user.id, { title: 'Last Year', start_date: `${currentYear - 1}-07-01`, end_date: `${currentYear - 1}-07-10` });
createTrip(testDb, user.id, {
title: 'This Year',
start_date: `${currentYear}-06-01`,
end_date: `${currentYear}-06-10`,
});
createTrip(testDb, user.id, {
title: 'Last Year',
start_date: `${currentYear - 1}-07-01`,
end_date: `${currentYear - 1}-07-10`,
});
const stats = await getStats(user.id);
@@ -445,7 +487,9 @@ describe('getVisitedRegions', () => {
it('ATLAS-UNIT-018: returns manually marked regions even when user has no places with coordinates', async () => {
const { user } = createUser(testDb);
testDb.prepare('INSERT INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(user.id, 'DE');
testDb.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)').run(user.id, 'DE-BY', 'Bayern', 'DE');
testDb
.prepare('INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)')
.run(user.id, 'DE-BY', 'Bayern', 'DE');
const result = await getVisitedRegions(user.id);
@@ -458,16 +502,19 @@ describe('getVisitedRegions', () => {
it('ATLAS-UNIT-019: geocodes places with lat/lng using reverseGeocodeRegion via fetch', async () => {
vi.useFakeTimers();
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
address: {
country_code: 'fr',
'ISO3166-2-lvl4': 'FR-75',
state: 'Île-de-France',
},
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({
address: {
country_code: 'fr',
'ISO3166-2-lvl4': 'FR-75',
state: 'Île-de-France',
},
}),
}),
}));
);
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id, { title: 'Paris Trip' });
@@ -491,9 +538,11 @@ describe('getVisitedRegions', () => {
const place = insertPlaceWithCoords(testDb, trip.id, 'Cached Place', 48.85, 2.35);
// Pre-populate the place_regions cache so the fetch path is never reached
testDb.prepare(
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)'
).run(place.id, 'FR', 'FR-75', 'Île-de-France');
testDb
.prepare(
'INSERT OR REPLACE INTO place_regions (place_id, country_code, region_code, region_name) VALUES (?, ?, ?, ?)',
)
.run(place.id, 'FR', 'FR-75', 'Île-de-France');
const mockFetch = vi.fn().mockResolvedValue({ ok: true, json: async () => ({}) });
vi.stubGlobal('fetch', mockFetch);
+9 -7
View File
@@ -1,3 +1,6 @@
import { getClientIp } from '../../../src/services/auditLog';
import type { Request } from 'express';
import { describe, it, expect, vi } from 'vitest';
// Prevent file I/O side effects at module load time
@@ -20,13 +23,12 @@ vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ get: vi.fn(), run: vi.fn() }) },
}));
import { getClientIp } from '../../../src/services/auditLog';
import type { Request } from 'express';
function makeReq(options: {
xff?: string | string[];
remoteAddress?: string;
} = {}): Request {
function makeReq(
options: {
xff?: string | string[];
remoteAddress?: string;
} = {},
): Request {
return {
headers: {
...(options.xff !== undefined ? { 'x-forwarded-for': options.xff } : {}),
+16 -13
View File
@@ -1,3 +1,15 @@
import {
utcSuffix,
stripUserForClient,
maskKey,
avatarUrl,
normalizeBackupCode,
hashBackupCode,
generateBackupCodes,
parseBackupCodeHashes,
} from '../../../src/services/authService';
import type { User } from '../../../src/types';
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../../src/db/database', () => ({
@@ -11,23 +23,14 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({
maybe_encrypt_api_key: vi.fn((v) => v),
encrypt_api_key: vi.fn((v) => v),
}));
vi.mock('../../../src/services/permissions', () => ({ getAllPermissions: vi.fn(() => ({})), checkPermission: vi.fn() }));
vi.mock('../../../src/services/permissions', () => ({
getAllPermissions: vi.fn(() => ({})),
checkPermission: vi.fn(),
}));
vi.mock('../../../src/services/ephemeralTokens', () => ({ createEphemeralToken: vi.fn() }));
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
vi.mock('../../../src/scheduler', () => ({ startTripReminders: vi.fn(), buildCronExpression: vi.fn() }));
import {
utcSuffix,
stripUserForClient,
maskKey,
avatarUrl,
normalizeBackupCode,
hashBackupCode,
generateBackupCodes,
parseBackupCodeHashes,
} from '../../../src/services/authService';
import type { User } from '../../../src/types';
// ── utcSuffix ────────────────────────────────────────────────────────────────
describe('utcSuffix', () => {
@@ -1,3 +1,32 @@
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
updateSettings,
getSettings,
listUsers,
getAppSettings,
validateKeys,
isOidcOnlyMode,
resolveAuthToggles,
setupMfa,
enableMfa,
disableMfa,
validateInviteToken,
registerUser,
loginUser,
changePassword,
verifyMfaLogin,
createMcpToken,
deleteMcpToken,
} from '../../../src/services/authService';
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
/**
* authServiceDb.test.ts
*
@@ -23,7 +52,7 @@ const { testDb, dbMock } = vi.hoisted(() => {
canAccessTrip: (tripId: any, userId: number) =>
db
.prepare(
`SELECT t.id, t.user_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)`
`SELECT t.id, t.user_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) =>
@@ -61,35 +90,6 @@ vi.mock('../../../src/scheduler', () => ({
VALID_INTERVALS: ['daily', 'weekly', 'monthly'],
}));
// ---------------------------------------------------------------------------
// Imports (after mocks)
// ---------------------------------------------------------------------------
import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest';
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, createInviteToken } from '../../helpers/factories';
import {
updateSettings,
getSettings,
listUsers,
getAppSettings,
validateKeys,
isOidcOnlyMode,
resolveAuthToggles,
setupMfa,
enableMfa,
disableMfa,
validateInviteToken,
registerUser,
loginUser,
changePassword,
verifyMfaLogin,
createMcpToken,
deleteMcpToken,
} from '../../../src/services/authService';
// ---------------------------------------------------------------------------
// Lifecycle
// ---------------------------------------------------------------------------
@@ -229,9 +229,7 @@ describe('getAppSettings', () => {
it('AUTH-DB-014: returns settings object for admin with known key allow_registration', () => {
const { user } = createAdmin(testDb);
testDb
.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')")
.run();
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'true')").run();
const result = getAppSettings(user.id);
expect(result.status).toBeUndefined();
expect(result.data).toBeDefined();
@@ -282,9 +280,7 @@ describe('validateKeys', () => {
const { user } = createAdmin(testDb);
testDb.prepare('UPDATE users SET maps_api_key = ? WHERE id = ?').run('test-key', user.id);
const fetchSpy = vi
.spyOn(global, 'fetch')
.mockRejectedValueOnce(new Error('Network failure'));
const fetchSpy = vi.spyOn(global, 'fetch').mockRejectedValueOnce(new Error('Network failure'));
const result = await validateKeys(user.id);
expect(result.maps).toBe(false);
@@ -330,7 +326,11 @@ describe('isOidcOnlyMode', () => {
describe('resolveAuthToggles', () => {
afterEach(() => {
vi.unstubAllEnvs();
testDb.prepare("DELETE FROM app_settings WHERE key IN ('password_login','password_registration','oidc_login','oidc_registration','oidc_only','allow_registration')").run();
testDb
.prepare(
"DELETE FROM app_settings WHERE key IN ('password_login','password_registration','oidc_login','oidc_registration','oidc_only','allow_registration')",
)
.run();
});
it('AUTH-DB-022a: returns all true by default (no DB keys, no env override)', () => {
@@ -643,9 +643,9 @@ describe('MCP token service', () => {
it('AUTH-DB-044: createMcpToken returns 400 when user has 10 tokens already', () => {
const { user } = createUser(testDb);
for (let i = 0; i < 10; i++) {
testDb.prepare(
'INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)'
).run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`);
testDb
.prepare('INSERT INTO mcp_tokens (user_id, name, token_hash, token_prefix) VALUES (?, ?, ?, ?)')
.run(user.id, `Token ${i}`, `hash${i}`, `trek_prefix${i}`);
}
const result = createMcpToken(user.id, 'One More');
expect(result.status).toBe(400);
@@ -2,6 +2,22 @@
* Unit tests for backupService.
* Covers BACKUP-031 to BACKUP-060.
*/
import {
formatSize,
parseIntField,
parseAutoBackupBody,
isValidBackupFilename,
checkRateLimit,
createBackup,
deleteBackup,
restoreFromZip,
BACKUP_RATE_WINDOW,
backupFilePath,
backupFileExists,
listBackups,
updateAutoSettings,
} from '../../../src/services/backupService';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ---------------------------------------------------------------------------
@@ -70,22 +86,6 @@ vi.mock('../../../src/scheduler', () => ({
start: vi.fn(),
}));
import {
formatSize,
parseIntField,
parseAutoBackupBody,
isValidBackupFilename,
checkRateLimit,
createBackup,
deleteBackup,
restoreFromZip,
BACKUP_RATE_WINDOW,
backupFilePath,
backupFileExists,
listBackups,
updateAutoSettings,
} from '../../../src/services/backupService';
// ---------------------------------------------------------------------------
// formatSize
// ---------------------------------------------------------------------------
@@ -341,7 +341,9 @@ describe('BACKUP-036 createBackup', () => {
it('BACKUP-036b — WAL checkpoint error is swallowed (non-critical)', async () => {
// db.exec throws on WAL checkpoint
dbMock.db.exec.mockImplementationOnce(() => { throw new Error('WAL checkpoint failed'); });
dbMock.db.exec.mockImplementationOnce(() => {
throw new Error('WAL checkpoint failed');
});
const writableEvents: Record<string, Function> = {};
const fakeWriteStream = {
@@ -432,10 +434,7 @@ describe('BACKUP-036 createBackup', () => {
await createBackup();
// archive.file should have been called with the db path
expect(archiverInstanceMock.file).toHaveBeenCalledWith(
expect.stringContaining('travel.db'),
{ name: 'travel.db' }
);
expect(archiverInstanceMock.file).toHaveBeenCalledWith(expect.stringContaining('travel.db'), { name: 'travel.db' });
});
it('BACKUP-036e — includes uploads directory when it exists', async () => {
@@ -464,10 +463,7 @@ describe('BACKUP-036 createBackup', () => {
await createBackup();
expect(archiverInstanceMock.directory).toHaveBeenCalledWith(
expect.stringContaining('uploads'),
'uploads'
);
expect(archiverInstanceMock.directory).toHaveBeenCalledWith(expect.stringContaining('uploads'), 'uploads');
});
});
@@ -486,9 +482,7 @@ describe('BACKUP-037 deleteBackup', () => {
deleteBackup('backup-2026-04-06T12-00-00.zip');
expect(fsMock.unlinkSync).toHaveBeenCalledOnce();
expect(fsMock.unlinkSync).toHaveBeenCalledWith(
expect.stringContaining('backup-2026-04-06T12-00-00.zip')
);
expect(fsMock.unlinkSync).toHaveBeenCalledWith(expect.stringContaining('backup-2026-04-06T12-00-00.zip'));
});
it('BACKUP-037b — throws when unlinkSync throws (file not found)', () => {
@@ -565,9 +559,7 @@ describe('BACKUP-040 backupFileExists', () => {
it('BACKUP-040a — returns true when existsSync returns true', () => {
fsMock.existsSync.mockReturnValue(true);
expect(backupFileExists('backup-2026-01-01T00-00-00.zip')).toBe(true);
expect(fsMock.existsSync).toHaveBeenCalledWith(
expect.stringContaining('backup-2026-01-01T00-00-00.zip')
);
expect(fsMock.existsSync).toHaveBeenCalledWith(expect.stringContaining('backup-2026-01-01T00-00-00.zip'));
});
it('BACKUP-040b — returns false when existsSync returns false', () => {
@@ -609,10 +601,7 @@ describe('BACKUP-041 listBackups', () => {
});
it('BACKUP-041c — sorts results newest-first', () => {
fsMock.readdirSync.mockReturnValue([
'backup-2026-01-01T00-00-00.zip',
'backup-2026-06-01T00-00-00.zip',
]);
fsMock.readdirSync.mockReturnValue(['backup-2026-01-01T00-00-00.zip', 'backup-2026-06-01T00-00-00.zip']);
fsMock.statSync.mockImplementation((p: string) => {
if (String(p).includes('2026-01-01')) {
return { size: 512, mtime: new Date('2026-01-01T00:00:00Z') };
@@ -628,11 +617,7 @@ describe('BACKUP-041 listBackups', () => {
});
it('BACKUP-041d — filters out non-.zip files', () => {
fsMock.readdirSync.mockReturnValue([
'backup-2026-01-01T00-00-00.zip',
'README.txt',
'backup-partial.tar.gz',
]);
fsMock.readdirSync.mockReturnValue(['backup-2026-01-01T00-00-00.zip', 'README.txt', 'backup-partial.tar.gz']);
fsMock.statSync.mockReturnValue({
size: 1024,
mtime: new Date('2026-01-01T00:00:00Z'),
@@ -666,9 +651,7 @@ describe('BACKUP-042 restoreFromZip — integrity check fails', () => {
it('BACKUP-042a — returns status 400 with integrity check error message', async () => {
setupSuccessfulExtraction();
fsMock.existsSync.mockImplementation((p: string) =>
String(p).endsWith('travel.db')
);
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db'));
fsMock.rmSync.mockReturnValue(undefined);
const fakeDbInstance = {
@@ -697,13 +680,12 @@ describe('BACKUP-043 restoreFromZip — missing required table', () => {
it('BACKUP-043a — returns status 400 with missing required table error', async () => {
setupSuccessfulExtraction();
fsMock.existsSync.mockImplementation((p: string) =>
String(p).endsWith('travel.db')
);
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db'));
fsMock.rmSync.mockReturnValue(undefined);
const fakeDbInstance = {
prepare: vi.fn()
prepare: vi
.fn()
.mockReturnValueOnce({
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
})
@@ -731,9 +713,7 @@ describe('BACKUP-044 restoreFromZip — Database constructor throws (invalid SQL
it('BACKUP-044a — returns status 400 with "not a valid SQLite database" error', async () => {
setupSuccessfulExtraction();
fsMock.existsSync.mockImplementation((p: string) =>
String(p).endsWith('travel.db')
);
fsMock.existsSync.mockImplementation((p: string) => String(p).endsWith('travel.db'));
fsMock.rmSync.mockReturnValue(undefined);
DatabaseMock.mockImplementation(() => {
@@ -756,18 +736,21 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => {
function setupAllTablesPresent() {
const fakeDbInstance = {
prepare: vi.fn()
prepare: vi
.fn()
.mockReturnValueOnce({
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
})
.mockReturnValueOnce({
all: vi.fn().mockReturnValue([
{ name: 'users' },
{ name: 'trips' },
{ name: 'trip_members' },
{ name: 'places' },
{ name: 'days' },
]),
all: vi
.fn()
.mockReturnValue([
{ name: 'users' },
{ name: 'trips' },
{ name: 'trip_members' },
{ name: 'places' },
{ name: 'days' },
]),
}),
close: vi.fn(),
};
@@ -798,8 +781,12 @@ describe('BACKUP-045 restoreFromZip — full success path (no uploads)', () => {
setupAllTablesPresent();
const callOrder: string[] = [];
dbMock.closeDb.mockImplementation(() => { callOrder.push('closeDb'); });
fsMock.copyFileSync.mockImplementation(() => { callOrder.push('copyFileSync'); });
dbMock.closeDb.mockImplementation(() => {
callOrder.push('closeDb');
});
fsMock.copyFileSync.mockImplementation(() => {
callOrder.push('copyFileSync');
});
fsMock.unlinkSync.mockReturnValue(undefined);
fsMock.rmSync.mockReturnValue(undefined);
@@ -844,18 +831,21 @@ describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
setupSuccessfulExtraction();
const fakeDbInstance = {
prepare: vi.fn()
prepare: vi
.fn()
.mockReturnValueOnce({
get: vi.fn().mockReturnValue({ integrity_check: 'ok' }),
})
.mockReturnValueOnce({
all: vi.fn().mockReturnValue([
{ name: 'users' },
{ name: 'trips' },
{ name: 'trip_members' },
{ name: 'places' },
{ name: 'days' },
]),
all: vi
.fn()
.mockReturnValue([
{ name: 'users' },
{ name: 'trips' },
{ name: 'trip_members' },
{ name: 'places' },
{ name: 'days' },
]),
}),
close: vi.fn(),
};
@@ -883,11 +873,10 @@ describe('BACKUP-046 restoreFromZip — with uploads directory', () => {
await restoreFromZip('/data/tmp/upload.zip');
expect(fsMock.cpSync).toHaveBeenCalledWith(
expect.stringContaining('uploads'),
expect.stringContaining('uploads'),
{ recursive: true, force: true }
);
expect(fsMock.cpSync).toHaveBeenCalledWith(expect.stringContaining('uploads'), expect.stringContaining('uploads'), {
recursive: true,
force: true,
});
});
});
@@ -908,7 +897,7 @@ describe('BACKUP-047 updateAutoSettings', () => {
expect(schedulerMock.saveSettings).toHaveBeenCalledOnce();
expect(schedulerMock.saveSettings).toHaveBeenCalledWith(
expect.objectContaining({ enabled: true, interval: 'weekly', hour: 6 })
expect.objectContaining({ enabled: true, interval: 'weekly', hour: 6 }),
);
});
@@ -1,3 +1,6 @@
import { calculateSettlement } from '../../../src/services/budgetService';
import type { BudgetItem, BudgetItemMember } from '../../../src/types';
import { describe, it, expect, vi, beforeEach } from 'vitest';
// ── DB mock setup ────────────────────────────────────────────────────────────
@@ -29,16 +32,18 @@ const mockDb = vi.hoisted(() => {
vi.mock('../../../src/db/database', () => mockDb);
import { calculateSettlement } from '../../../src/services/budgetService';
import type { BudgetItem, BudgetItemMember } from '../../../src/types';
// ── Helpers ──────────────────────────────────────────────────────────────────
function makeItem(id: number, total_price: number, trip_id = 1): BudgetItem {
return { id, trip_id, name: `Item ${id}`, total_price, category: 'Other' } as BudgetItem;
}
function makeMember(budget_item_id: number, user_id: number, paid: boolean | 0 | 1, username: string): BudgetItemMember & { budget_item_id: number } {
function makeMember(
budget_item_id: number,
user_id: number,
paid: boolean | 0 | 1,
username: string,
): BudgetItemMember & { budget_item_id: number } {
return {
budget_item_id,
user_id,
@@ -82,28 +87,22 @@ describe('calculateSettlement', () => {
});
it('returns no flows when no one is marked as paid', () => {
setupDb(
[makeItem(1, 100)],
[makeMember(1, 1, 0, 'alice'), makeMember(1, 2, 0, 'bob')],
);
setupDb([makeItem(1, 100)], [makeMember(1, 1, 0, 'alice'), makeMember(1, 2, 0, 'bob')]);
const result = calculateSettlement(1);
expect(result.flows).toEqual([]);
});
it('2 members, 1 payer: payer is owed half, non-payer owes half', () => {
// Item: $100. Alice paid, Bob did not. Each owes $50. Alice net: +$50. Bob net: -$50.
setupDb(
[makeItem(1, 100)],
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
);
setupDb([makeItem(1, 100)], [makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')]);
const result = calculateSettlement(1);
const alice = result.balances.find(b => b.user_id === 1)!;
const bob = result.balances.find(b => b.user_id === 2)!;
const alice = result.balances.find((b) => b.user_id === 1)!;
const bob = result.balances.find((b) => b.user_id === 2)!;
expect(alice.balance).toBe(50);
expect(bob.balance).toBe(-50);
expect(result.flows).toHaveLength(1);
expect(result.flows[0].from.user_id).toBe(2); // Bob owes
expect(result.flows[0].to.user_id).toBe(1); // Alice is owed
expect(result.flows[0].to.user_id).toBe(1); // Alice is owed
expect(result.flows[0].amount).toBe(50);
});
@@ -114,9 +113,9 @@ describe('calculateSettlement', () => {
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'), makeMember(1, 3, 0, 'carol')],
);
const result = calculateSettlement(1);
const alice = result.balances.find(b => b.user_id === 1)!;
const bob = result.balances.find(b => b.user_id === 2)!;
const carol = result.balances.find(b => b.user_id === 3)!;
const alice = result.balances.find((b) => b.user_id === 1)!;
const bob = result.balances.find((b) => b.user_id === 2)!;
const carol = result.balances.find((b) => b.user_id === 3)!;
expect(alice.balance).toBe(60);
expect(bob.balance).toBe(-30);
expect(carol.balance).toBe(-30);
@@ -140,14 +139,11 @@ describe('calculateSettlement', () => {
it('flow direction: from is debtor (owes), to is creditor (is owed)', () => {
// Alice paid $100 for 2 people. Bob owes Alice $50.
setupDb(
[makeItem(1, 100)],
[makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')],
);
setupDb([makeItem(1, 100)], [makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob')]);
const result = calculateSettlement(1);
const flow = result.flows[0];
expect(flow.from.username).toBe('bob'); // debtor
expect(flow.to.username).toBe('alice'); // creditor
expect(flow.from.username).toBe('bob'); // debtor
expect(flow.to.username).toBe('alice'); // creditor
});
it('amounts are rounded to 2 decimal places', () => {
@@ -176,13 +172,15 @@ describe('calculateSettlement', () => {
setupDb(
[makeItem(1, 100), makeItem(2, 60)],
[
makeMember(1, 1, 1, 'alice'), makeMember(1, 2, 0, 'bob'),
makeMember(2, 1, 0, 'alice'), makeMember(2, 2, 1, 'bob'),
makeMember(1, 1, 1, 'alice'),
makeMember(1, 2, 0, 'bob'),
makeMember(2, 1, 0, 'alice'),
makeMember(2, 2, 1, 'bob'),
],
);
const result = calculateSettlement(1);
const alice = result.balances.find(b => b.user_id === 1)!;
const bob = result.balances.find(b => b.user_id === 2)!;
const alice = result.balances.find((b) => b.user_id === 1)!;
const bob = result.balances.find((b) => b.user_id === 2)!;
expect(alice.balance).toBe(20);
expect(bob.balance).toBe(-20);
expect(result.flows).toHaveLength(1);
@@ -2,6 +2,18 @@
* Unit tests for categoryService CAT-SVC-001 through CAT-SVC-015.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
listCategories,
createCategory,
getCategoryById,
updateCategory,
deleteCategory,
} from '../../../src/services/categoryService';
import { createUser } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -30,18 +42,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import {
listCategories,
createCategory,
getCategoryById,
updateCategory,
deleteCategory,
} from '../../../src/services/categoryService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -3,6 +3,23 @@
* Covers votePoll edge cases, listMessages pagination, deleteMessage ownership,
* updateNote partial fields, fetchLinkPreview, avatarUrl, createMessage reply validation.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
avatarUrl,
votePoll,
listMessages,
createMessage,
deleteMessage,
updateNote,
createNote,
createPoll,
closePoll,
fetchLinkPreview,
} from '../../../src/services/collabService';
import { createUser, createTrip } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
// ── DB setup ─────────────────────────────────────────────────────────────────
@@ -19,11 +36,15 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
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),
`,
)
.get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
@@ -47,23 +68,6 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
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,
votePoll,
listMessages,
createMessage,
deleteMessage,
updateNote,
createNote,
createPoll,
closePoll,
fetchLinkPreview,
} from '../../../src/services/collabService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -184,7 +188,7 @@ describe('listMessages', () => {
const id3 = r3.message!.id;
const msgs = listMessages(trip.id, id3);
expect(msgs.length).toBe(2);
const texts = msgs.map(m => m.text);
const texts = msgs.map((m) => m.text);
expect(texts).toContain('First');
expect(texts).toContain('Second');
expect(texts).not.toContain('Third');
@@ -205,7 +209,9 @@ describe('listMessages', () => {
const { user1, trip } = setup();
const r = 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, '👍');
testDb
.prepare('INSERT INTO collab_message_reactions (message_id, user_id, emoji) VALUES (?, ?, ?)')
.run(msgId, user1.id, '👍');
const msgs = listMessages(trip.id);
expect(msgs[0].reactions).toBeDefined();
@@ -266,7 +272,11 @@ 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 = createNote(trip.id, user1.id, {
title: 'Original',
content: 'Some content',
website: 'https://example.com',
});
updateNote(trip.id, note.id, { title: 'Updated' });
@@ -331,9 +341,11 @@ describe('fetchLinkPreview', () => {
});
it('COLLAB-SVC-025: returns OG title and description from HTML', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => `
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
text: async () => `
<html>
<head>
<meta property="og:title" content="Test Title" />
@@ -343,7 +355,8 @@ describe('fetchLinkPreview', () => {
</head>
</html>
`,
}));
}),
);
const result = await fetchLinkPreview('https://example.com/page');
expect(result.title).toBe('Test Title');
@@ -353,20 +366,26 @@ describe('fetchLinkPreview', () => {
});
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>`,
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
text: async () => `<html><head><title>Page Title</title></head></html>`,
}),
);
const result = await fetchLinkPreview('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 () => '',
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
text: async () => '',
}),
);
const result = await fetchLinkPreview('https://example.com/bad');
expect(result.title).toBeNull();
@@ -390,14 +409,17 @@ describe('fetchLinkPreview', () => {
});
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 () => `
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
text: async () => `
<html><head>
<meta name="description" content="Meta description here" />
</head></html>
`,
}));
}),
);
const result = await fetchLinkPreview('https://example.com/meta');
expect(result.description).toBe('Meta description here');
+2 -2
View File
@@ -1,7 +1,7 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { cookieOptions } from '../../../src/services/cookie';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
describe('cookieOptions', () => {
afterEach(() => {
vi.unstubAllEnvs();
+76 -45
View File
@@ -2,48 +2,8 @@
* Unit tests for dayService DAY-SVC-001 through DAY-SVC-030.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } 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: (placeId: any) => {
const place: any = db.prepare(`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
`).get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createDay, createPlace, createDayAssignment, createDayAccommodation } from '../../helpers/factories';
import { createTables } from '../../../src/db/schema';
import {
verifyTripAccess,
getAssignmentsForDay,
@@ -59,6 +19,69 @@ import {
updateAccommodation,
deleteAccommodation,
} from '../../../src/services/dayService';
import {
createUser,
createTrip,
createDay,
createPlace,
createDayAssignment,
createDayAccommodation,
} from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } 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: (placeId: any) => {
const place: any = db
.prepare(
`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
`,
)
.get(placeId);
if (!place) return null;
const tags = db
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
.all(placeId);
return {
...place,
category: place.category_id
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
: null,
tags,
};
},
canAccessTrip: (tripId: any, userId: number) =>
db
.prepare(
`SELECT t.id, t.user_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-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
beforeAll(() => {
createTables(testDb);
@@ -289,7 +312,9 @@ describe('createAccommodation', () => {
const place = createPlace(testDb, trip.id, { name: 'City Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
place_id: place.id,
start_day_id: day.id,
end_day_id: day.id,
}) as any;
const reservation = testDb.prepare('SELECT * FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
@@ -329,7 +354,9 @@ describe('updateAccommodation', () => {
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
place_id: place.id,
start_day_id: day.id,
end_day_id: day.id,
}) as any;
const existing = getAccommodation(accom.id, trip.id)!;
@@ -350,7 +377,9 @@ describe('updateAccommodation', () => {
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
place_id: place.id,
start_day_id: day.id,
end_day_id: day.id,
confirmation: 'ABC123',
}) as any;
@@ -371,7 +400,9 @@ describe('deleteAccommodation', () => {
const day = createDay(testDb, trip.id) as any;
const place = createPlace(testDb, trip.id, { name: 'Hotel' }) as any;
const accom = createAccommodation(trip.id, {
place_id: place.id, start_day_id: day.id, end_day_id: day.id,
place_id: place.id,
start_day_id: day.id,
end_day_id: day.id,
}) as any;
const reservation = testDb.prepare('SELECT id FROM reservations WHERE accommodation_id = ?').get(accom.id) as any;
@@ -2,9 +2,10 @@
* Unit tests for inAppNotificationActions NOTIF-ACT-001 through NOTIF-ACT-008.
* Pure Map registry no DB or external dependencies.
*/
import { describe, it, expect } from 'vitest';
import { getAction } from '../../../src/services/inAppNotificationActions';
import { describe, it, expect } from 'vitest';
describe('getAction — built-in registrations', () => {
it('NOTIF-ACT-001 — test_approve is pre-registered', () => {
const handler = getAction('test_approve');
@@ -17,5 +18,4 @@ describe('getAction — built-in registrations', () => {
expect(handler).toBeDefined();
expect(typeof handler).toBe('function');
});
});
@@ -2,6 +2,16 @@
* Unit tests for in-app notification preference filtering in createNotification().
* Covers INOTIF-001 to INOTIF-004.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
createNotification,
createNotificationForRecipient,
respondToBoolean,
} from '../../../src/services/inAppNotifications';
import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -32,12 +42,6 @@ vi.mock('../../../src/config', () => ({
const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() }));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: broadcastMock }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, disableNotificationPref } from '../../helpers/factories';
import { createNotification, createNotificationForRecipient, respondToBoolean } from '../../../src/services/inAppNotifications';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -100,7 +104,8 @@ describe('createNotification — preference filtering', () => {
disableNotificationPref(testDb, recipient2.id, 'trip_invite', 'inapp');
// Use a trip to target both members
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Test Trip', sender.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Test Trip', sender.id)
.lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient1.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, recipient2.id);
@@ -159,11 +164,13 @@ describe('createNotification — preference filtering', () => {
navigate_target: '/trips/99',
},
recipient.id,
{ username: 'admin', avatar: null }
{ username: 'admin', avatar: null },
);
expect(id).toBeTypeOf('number');
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as { recipient_id: number; navigate_target: string } | undefined;
const row = testDb.prepare('SELECT * FROM notifications WHERE id = ?').get(id) as
| { recipient_id: number; navigate_target: string }
| undefined;
expect(row).toBeDefined();
expect(row!.recipient_id).toBe(recipient.id);
expect(row!.navigate_target).toBe('/trips/99');
@@ -204,7 +211,9 @@ describe('createNotification — preference filtering', () => {
// ─────────────────────────────────────────────────────────────────────────────
function insertBooleanNotification(recipientId: number, senderId: number | null = null): number {
const result = testDb.prepare(`
const result = testDb
.prepare(
`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id,
title_key, title_params, text_key, text_params,
@@ -213,17 +222,23 @@ function insertBooleanNotification(recipientId: number, senderId: number | null
'notif.action.accept', 'notif.action.decline',
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
)
`).run(recipientId, senderId, recipientId);
`,
)
.run(recipientId, senderId, recipientId);
return result.lastInsertRowid as number;
}
function insertSimpleNotification(recipientId: number): number {
const result = testDb.prepare(`
const result = testDb
.prepare(
`
INSERT INTO notifications (
type, scope, target, sender_id, recipient_id,
title_key, title_params, text_key, text_params
) VALUES ('simple', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}')
`).run(recipientId, recipientId);
`,
)
.run(recipientId, recipientId);
return result.lastInsertRowid as number;
}
+191 -154
View File
@@ -2,49 +2,8 @@
* Unit tests for journeyService (JOURNEY-SVC-001 through JOURNEY-SVC-038).
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } 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: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import {
createUser,
createTrip,
createJourney,
createJourneyEntry,
addJourneyContributor,
createPlace,
createDay,
createDayAssignment,
addTripPhoto,
} from '../../helpers/factories';
import { createTables } from '../../../src/db/schema';
import {
canAccessJourney,
isOwner,
@@ -77,6 +36,47 @@ import {
updatePhoto,
listUserTrips,
} from '../../../src/services/journeyService';
import {
createUser,
createTrip,
createJourney,
createJourneyEntry,
addJourneyContributor,
createPlace,
createDay,
createDayAssignment,
addTripPhoto,
} from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } 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: () => null,
isOwner: () => false,
};
return { testDb: db, dbMock: mock };
});
vi.mock('../../../src/db/database', () => dbMock);
vi.mock('../../../src/config', () => ({
JWT_SECRET: 'test-secret',
ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2',
updateJwtSecret: () => {},
}));
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
beforeAll(() => {
createTables(testDb);
@@ -256,9 +256,9 @@ describe('createJourney (service)', () => {
expect(journey.status).toBe('active');
// owner should be added as contributor
const contrib = testDb.prepare(
'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journey.id, user.id) as { role: string } | undefined;
const contrib = testDb
.prepare('SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?')
.get(journey.id, user.id) as { role: string } | undefined;
expect(contrib).toBeDefined();
expect(contrib!.role).toBe('owner');
});
@@ -269,9 +269,9 @@ describe('createJourney (service)', () => {
const journey = svcCreateJourney(user.id, { title: 'Euro Trip', trip_ids: [trip.id] });
const link = testDb.prepare(
'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?'
).get(journey.id, trip.id);
const link = testDb
.prepare('SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?')
.get(journey.id, trip.id);
expect(link).toBeDefined();
});
});
@@ -411,9 +411,9 @@ describe('addTripToJourney / removeTripFromJourney', () => {
const result = addTripToJourney(journey.id, trip.id, user.id);
expect(result).toBe(true);
const link = testDb.prepare(
'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?'
).get(journey.id, trip.id);
const link = testDb
.prepare('SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?')
.get(journey.id, trip.id);
expect(link).toBeDefined();
});
@@ -426,14 +426,16 @@ describe('addTripToJourney / removeTripFromJourney', () => {
end_date: '2026-03-03',
});
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
const day025 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
const day025 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as {
id: number;
};
createDayAssignment(testDb, day025.id, place.id);
addTripToJourney(journey.id, trip.id, user.id);
const skeletons = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'"
).all(journey.id, place.id);
const skeletons = testDb
.prepare("SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'")
.all(journey.id, place.id);
expect(skeletons.length).toBe(1);
});
@@ -446,9 +448,9 @@ describe('addTripToJourney / removeTripFromJourney', () => {
const result = removeTripFromJourney(journey.id, trip.id, user.id);
expect(result).toBe(true);
const link = testDb.prepare(
'SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?'
).get(journey.id, trip.id);
const link = testDb
.prepare('SELECT * FROM journey_trips WHERE journey_id = ? AND trip_id = ?')
.get(journey.id, trip.id);
expect(link).toBeUndefined();
});
@@ -611,11 +613,17 @@ describe('deleteEntry', () => {
// Create a filled entry that originated from a trip skeleton
const now = Date.now();
testDb.prepare(`
testDb
.prepare(
`
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, story, mood, entry_date, location_name, visibility, sort_order, created_at, updated_at)
VALUES (?, ?, ?, ?, 'entry', 'Tokyo Tower', 'Amazing view!', 'amazing', '2026-03-01', 'Tokyo', 'private', 0, ?, ?)
`).run(journey.id, trip.id, place.id, user.id, now, now);
const entry = testDb.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?').get(journey.id, place.id) as any;
`,
)
.run(journey.id, trip.id, place.id, user.id, now, now);
const entry = testDb
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?')
.get(journey.id, place.id) as any;
const result = deleteEntry(entry.id, user.id);
expect(result).toBe(true);
@@ -737,9 +745,9 @@ describe('addContributor / updateContributorRole / removeContributor', () => {
const result = addContributor(journey.id, owner.id, newContrib.id, 'editor');
expect(result).toBe(true);
const row = testDb.prepare(
'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journey.id, newContrib.id) as { role: string } | undefined;
const row = testDb
.prepare('SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?')
.get(journey.id, newContrib.id) as { role: string } | undefined;
expect(row).toBeDefined();
expect(row!.role).toBe('editor');
});
@@ -774,9 +782,9 @@ describe('addContributor / updateContributorRole / removeContributor', () => {
const result = updateContributorRole(journey.id, owner.id, contrib.id, 'editor');
expect(result).toBe(true);
const row = testDb.prepare(
'SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journey.id, contrib.id) as { role: string };
const row = testDb
.prepare('SELECT role FROM journey_contributors WHERE journey_id = ? AND user_id = ?')
.get(journey.id, contrib.id) as { role: string };
expect(row.role).toBe('editor');
});
@@ -802,9 +810,9 @@ describe('addContributor / updateContributorRole / removeContributor', () => {
const result = removeContributor(journey.id, owner.id, contrib.id);
expect(result).toBe(true);
const row = testDb.prepare(
'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journey.id, contrib.id);
const row = testDb
.prepare('SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?')
.get(journey.id, contrib.id);
expect(row).toBeUndefined();
});
@@ -816,9 +824,9 @@ describe('addContributor / updateContributorRole / removeContributor', () => {
// (the SQL filters role != 'owner')
removeContributor(journey.id, owner.id, owner.id);
const row = testDb.prepare(
'SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?'
).get(journey.id, owner.id);
const row = testDb
.prepare('SELECT * FROM journey_contributors WHERE journey_id = ? AND user_id = ?')
.get(journey.id, owner.id);
expect(row).toBeDefined();
});
});
@@ -888,15 +896,17 @@ describe('syncTripPlaces', () => {
});
const place1 = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
const place2 = createPlace(testDb, trip.id, { name: 'Louvre' });
const days055 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[];
const days055 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as {
id: number;
}[];
createDayAssignment(testDb, days055[0].id, place1.id);
createDayAssignment(testDb, days055[1].id, place2.id);
syncTripPlaces(journey.id, trip.id, user.id);
const skeletons = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'"
).all(journey.id) as any[];
const skeletons = testDb
.prepare("SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'")
.all(journey.id) as any[];
expect(skeletons.length).toBe(2);
const names = skeletons.map((s: any) => s.title).sort();
expect(names).toEqual(['Eiffel Tower', 'Louvre']);
@@ -911,15 +921,17 @@ describe('syncTripPlaces', () => {
end_date: '2026-05-02',
});
const place056 = createPlace(testDb, trip.id, { name: 'Notre Dame' });
const day056 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
const day056 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as {
id: number;
};
createDayAssignment(testDb, day056.id, place056.id);
syncTripPlaces(journey.id, trip.id, user.id);
syncTripPlaces(journey.id, trip.id, user.id); // second call
const skeletons = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'"
).all(journey.id);
const skeletons = testDb
.prepare("SELECT * FROM journey_entries WHERE journey_id = ? AND type = 'skeleton'")
.all(journey.id);
expect(skeletons.length).toBe(1);
});
@@ -932,17 +944,17 @@ describe('syncTripPlaces', () => {
start_date: '2026-06-10',
end_date: '2026-06-12',
});
const day = testDb.prepare(
"SELECT * FROM days WHERE trip_id = ? AND date = '2026-06-11'"
).get(trip.id) as { id: number };
const day = testDb.prepare("SELECT * FROM days WHERE trip_id = ? AND date = '2026-06-11'").get(trip.id) as {
id: number;
};
const place = createPlace(testDb, trip.id, { name: 'Colosseum' });
createDayAssignment(testDb, day.id, place.id);
syncTripPlaces(journey.id, trip.id, user.id);
const skeleton = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?"
).get(journey.id, place.id) as any;
const skeleton = testDb
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?')
.get(journey.id, place.id) as any;
expect(skeleton).toBeDefined();
expect(skeleton.entry_date).toBe('2026-06-11');
});
@@ -963,13 +975,15 @@ describe('onPlaceCreated', () => {
// Create a new place after trip is linked
const place = createPlace(testDb, trip.id, { name: 'Sagrada Familia' });
const day058 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
const day058 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as {
id: number;
};
createDayAssignment(testDb, day058.id, place.id);
onPlaceCreated(trip.id, place.id);
const skeleton = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'"
).get(journey.id, place.id);
const skeleton = testDb
.prepare("SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'")
.get(journey.id, place.id);
expect(skeleton).toBeDefined();
});
@@ -980,9 +994,7 @@ describe('onPlaceCreated', () => {
onPlaceCreated(trip.id, place.id);
const entries = testDb.prepare(
"SELECT * FROM journey_entries WHERE source_place_id = ?"
).all(place.id);
const entries = testDb.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(place.id);
expect(entries.length).toBe(0);
});
@@ -997,14 +1009,16 @@ describe('onPlaceCreated', () => {
addTripToJourney(journey.id, trip.id, user.id);
const place = createPlace(testDb, trip.id, { name: 'Arc de Triomphe' });
const day060 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
const day060 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as {
id: number;
};
createDayAssignment(testDb, day060.id, place.id);
onPlaceCreated(trip.id, place.id);
onPlaceCreated(trip.id, place.id); // second call
const entries = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?"
).all(journey.id, place.id);
const entries = testDb
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?')
.all(journey.id, place.id);
expect(entries.length).toBe(1);
});
});
@@ -1019,7 +1033,9 @@ describe('onPlaceUpdated', () => {
end_date: '2026-08-03',
});
const place = createPlace(testDb, trip.id, { name: 'Old Name' });
const day061 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
const day061 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as {
id: number;
};
createDayAssignment(testDb, day061.id, place.id);
addTripToJourney(journey.id, trip.id, user.id);
@@ -1027,9 +1043,9 @@ describe('onPlaceUpdated', () => {
testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('New Name', 'New Address', place.id);
onPlaceUpdated(place.id);
const entry = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'"
).get(journey.id, place.id) as any;
const entry = testDb
.prepare("SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'")
.get(journey.id, place.id) as any;
expect(entry).toBeDefined();
expect(entry.title).toBe('New Name');
expect(entry.location_name).toBe('New Address');
@@ -1044,23 +1060,25 @@ describe('onPlaceUpdated', () => {
end_date: '2026-08-02',
});
const place = createPlace(testDb, trip.id, { name: 'Original Place' });
const day062 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
const day062 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as {
id: number;
};
createDayAssignment(testDb, day062.id, place.id);
addTripToJourney(journey.id, trip.id, user.id);
// Promote the skeleton to a full entry
const skeleton = testDb.prepare(
"SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?"
).get(journey.id, place.id) as { id: number };
const skeleton = testDb
.prepare('SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?')
.get(journey.id, place.id) as { id: number };
updateEntry(skeleton.id, user.id, { story: 'My story', title: 'Custom Title' });
// Now update the place
testDb.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?').run('Changed Place', 'Changed Addr', place.id);
testDb
.prepare('UPDATE places SET name = ?, address = ? WHERE id = ?')
.run('Changed Place', 'Changed Addr', place.id);
onPlaceUpdated(place.id);
const entry = testDb.prepare(
"SELECT * FROM journey_entries WHERE id = ?"
).get(skeleton.id) as any;
const entry = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(skeleton.id) as any;
expect(entry.title).toBe('Custom Title'); // title unchanged
expect(entry.location_name).toBe('Changed Addr'); // location updated
});
@@ -1073,9 +1091,7 @@ describe('onPlaceUpdated', () => {
// Should not throw
onPlaceUpdated(place.id);
const entries = testDb.prepare(
"SELECT * FROM journey_entries WHERE source_place_id = ?"
).all(place.id);
const entries = testDb.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').all(place.id);
expect(entries.length).toBe(0);
});
});
@@ -1094,9 +1110,7 @@ describe('onPlaceDeleted', () => {
onPlaceDeleted(place.id);
const entry = testDb.prepare(
"SELECT * FROM journey_entries WHERE source_place_id = ?"
).get(place.id);
const entry = testDb.prepare('SELECT * FROM journey_entries WHERE source_place_id = ?').get(place.id);
expect(entry).toBeUndefined();
});
@@ -1109,21 +1123,21 @@ describe('onPlaceDeleted', () => {
end_date: '2026-09-02',
});
const place = createPlace(testDb, trip.id, { name: 'Detach Place' });
const day065 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
const day065 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as {
id: number;
};
createDayAssignment(testDb, day065.id, place.id);
addTripToJourney(journey.id, trip.id, user.id);
// Promote the skeleton to a filled entry
const skeleton = testDb.prepare(
"SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?"
).get(journey.id, place.id) as { id: number };
const skeleton = testDb
.prepare('SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ?')
.get(journey.id, place.id) as { id: number };
updateEntry(skeleton.id, user.id, { story: 'I really enjoyed this place' });
onPlaceDeleted(place.id);
const entry = testDb.prepare(
"SELECT * FROM journey_entries WHERE id = ?"
).get(skeleton.id) as any;
const entry = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(skeleton.id) as any;
expect(entry).toBeDefined();
expect(entry.source_place_id).toBeNull();
expect(entry.source_trip_id).toBeNull();
@@ -1209,11 +1223,15 @@ describe('setPhotoProvider', () => {
setPhotoProvider(photo!.id, 'immich', 'immich-asset-789', user.id);
const updated = testDb.prepare(`
const updated = testDb
.prepare(
`
SELECT jp.*, tkp.provider, tkp.asset_id, tkp.owner_id
FROM journey_photos jp JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.id = ?
`).get(photo!.id) as any;
`,
)
.get(photo!.id) as any;
expect(updated.provider).toBe('immich');
expect(updated.asset_id).toBe('immich-asset-789');
expect(updated.owner_id).toBe(user.id);
@@ -1336,7 +1354,9 @@ describe('Edge cases', () => {
const { user } = createUser(testDb);
const journey = createJourney(testDb, user.id);
const result = updateJourney(journey.id, user.id, { cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)' });
const result = updateJourney(journey.id, user.id, {
cover_gradient: 'linear-gradient(to right, #ff0000, #0000ff)',
});
expect(result).not.toBeNull();
expect((result as any).cover_gradient).toBe('linear-gradient(to right, #ff0000, #0000ff)');
@@ -1398,11 +1418,15 @@ describe('Edge cases', () => {
addTripToJourney(journey.id, trip.id, user.id);
// Trip photos now go straight into the journey gallery (no wrapper entry).
const photos = testDb.prepare(`
const photos = testDb
.prepare(
`
SELECT jp.*, tkp.asset_id FROM journey_photos jp
JOIN trek_photos tkp ON tkp.id = jp.photo_id
WHERE jp.journey_id = ?
`).all(journey.id);
`,
)
.all(journey.id);
expect(photos.length).toBe(1);
expect((photos[0] as any).asset_id).toBe('immich-photo-1');
});
@@ -1417,29 +1441,29 @@ describe('Edge cases', () => {
});
const place1 = createPlace(testDb, trip.id, { name: 'Skeleton Place' });
const place2 = createPlace(testDb, trip.id, { name: 'Filled Place' });
const days087 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as { id: number }[];
const days087 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 2').all(trip.id) as {
id: number;
}[];
createDayAssignment(testDb, days087[0].id, place1.id);
createDayAssignment(testDb, days087[1].id, place2.id);
addTripToJourney(journey.id, trip.id, user.id);
// Promote one skeleton to a filled entry
const filled = testDb.prepare(
"SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'"
).get(journey.id, place2.id) as { id: number };
const filled = testDb
.prepare("SELECT id FROM journey_entries WHERE journey_id = ? AND source_place_id = ? AND type = 'skeleton'")
.get(journey.id, place2.id) as { id: number };
updateEntry(filled.id, user.id, { story: 'Now filled!' });
removeTripFromJourney(journey.id, trip.id, user.id);
// skeleton for place1 should be deleted
const skeletonRow = testDb.prepare(
"SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?"
).get(journey.id, place1.id);
const skeletonRow = testDb
.prepare('SELECT * FROM journey_entries WHERE journey_id = ? AND source_place_id = ?')
.get(journey.id, place1.id);
expect(skeletonRow).toBeUndefined();
// filled entry for place2 should be detached but still present
const filledRow = testDb.prepare(
"SELECT * FROM journey_entries WHERE id = ?"
).get(filled.id) as any;
const filledRow = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(filled.id) as any;
expect(filledRow).toBeDefined();
expect(filledRow.source_trip_id).toBeNull();
expect(filledRow.source_place_id).toBeNull();
@@ -1458,7 +1482,8 @@ describe('addProviderPhoto — passphrase', () => {
expect(photo).not.toBeNull();
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
const row = testDb
.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
.get('synologyphotos', 'pp-asset-1', user.id) as { passphrase: string | null } | undefined;
expect(row?.passphrase).not.toBeNull();
expect(typeof row?.passphrase).toBe('string');
@@ -1469,12 +1494,20 @@ describe('addProviderPhoto — passphrase', () => {
// -- reorderEntries (#846) ----------------------------------------------------
function insertEntry(journeyId: number, authorId: number, opts: { entry_date: string; entry_time?: string | null; sort_order?: number }): { id: number } {
function insertEntry(
journeyId: number,
authorId: number,
opts: { entry_date: string; entry_time?: string | null; sort_order?: number },
): { id: number } {
const now = Date.now();
const res = testDb.prepare(`
const res = testDb
.prepare(
`
INSERT INTO journey_entries (journey_id, author_id, type, entry_date, entry_time, sort_order, visibility, created_at, updated_at)
VALUES (?, ?, 'entry', ?, ?, ?, 'private', ?, ?)
`).run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now);
`,
)
.run(journeyId, authorId, opts.entry_date, opts.entry_time ?? null, opts.sort_order ?? 0, now, now);
return { id: Number(res.lastInsertRowid) };
}
@@ -1489,8 +1522,8 @@ describe('reorderEntries', () => {
expect(ok).toBe(true);
const entries = listEntries(journey.id, user.id)!;
const dayEntries = entries.filter(e => e.entry_date === '2026-08-01');
expect(dayEntries.map(e => e.id)).toEqual([e2.id, e1.id]);
const dayEntries = entries.filter((e) => e.entry_date === '2026-08-01');
expect(dayEntries.map((e) => e.id)).toEqual([e2.id, e1.id]);
});
it('JOURNEY-SVC-090: reorderEntries rejects ids from another journey', () => {
@@ -1513,7 +1546,7 @@ describe('reorderEntries', () => {
reorderEntries(journey.id, user.id, [day1b.id, day1a.id]);
const entries = listEntries(journey.id, user.id)!;
const day2Entry = entries.find(e => e.id === day2.id)!;
const day2Entry = entries.find((e) => e.id === day2.id)!;
expect(day2Entry.sort_order).toBe(0);
});
});
@@ -1527,7 +1560,9 @@ describe('syncTripPlaces sort_order', () => {
start_date: '2026-09-01',
end_date: '2026-09-02',
});
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number };
const day = testDb.prepare('SELECT id FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as {
id: number;
};
const p1 = createPlace(testDb, trip.id, { name: 'Place A' });
const p2 = createPlace(testDb, trip.id, { name: 'Place B' });
const p3 = createPlace(testDb, trip.id, { name: 'Place C' });
@@ -1537,10 +1572,10 @@ describe('syncTripPlaces sort_order', () => {
syncTripPlaces(journey.id, trip.id, user.id);
const rows = testDb.prepare(
'SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC'
).all(journey.id) as { sort_order: number }[];
const orders = rows.map(r => r.sort_order);
const rows = testDb
.prepare('SELECT sort_order FROM journey_entries WHERE journey_id = ? ORDER BY sort_order ASC')
.all(journey.id) as { sort_order: number }[];
const orders = rows.map((r) => r.sort_order);
expect(new Set(orders).size).toBe(orders.length);
expect(orders).toEqual([0, 1, 2]);
});
@@ -1557,16 +1592,18 @@ describe('onPlaceCreated sort_order', () => {
});
addTripToJourney(journey.id, trip.id, user.id);
const day = testDb.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1').get(trip.id) as { id: number; date: string };
const day = testDb
.prepare('SELECT id, date FROM days WHERE trip_id = ? ORDER BY date ASC LIMIT 1')
.get(trip.id) as { id: number; date: string };
insertEntry(journey.id, user.id, { entry_date: day.date, sort_order: 5 });
const place = createPlace(testDb, trip.id, { name: 'Late Addition' });
createDayAssignment(testDb, day.id, place.id);
onPlaceCreated(trip.id, place.id);
const newEntry = testDb.prepare(
'SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?'
).get(journey.id, place.id) as { sort_order: number } | undefined;
const newEntry = testDb
.prepare('SELECT sort_order FROM journey_entries WHERE journey_id = ? AND source_place_id = ?')
.get(journey.id, place.id) as { sort_order: number } | undefined;
expect(newEntry).toBeDefined();
expect(newEntry!.sort_order).toBe(6);
});
@@ -2,6 +2,19 @@
* Unit tests for journeyShareService JOURNEY-SHARE-001 through JOURNEY-SHARE-018.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
createOrUpdateJourneyShareLink,
getJourneyShareLink,
deleteJourneyShareLink,
validateShareTokenForPhoto,
validateShareTokenForAsset,
getPublicJourney,
} from '../../../src/services/journeyShareService';
import { createUser, createJourney, createJourneyEntry } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// -- DB setup -----------------------------------------------------------------
@@ -30,19 +43,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createJourney, createJourneyEntry } from '../../helpers/factories';
import {
createOrUpdateJourneyShareLink,
getJourneyShareLink,
deleteJourneyShareLink,
validateShareTokenForPhoto,
validateShareTokenForAsset,
getPublicJourney,
} from '../../../src/services/journeyShareService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -61,32 +61,48 @@ afterAll(() => {
/** Insert a trek_photos + journey_photos (gallery) + journey_entry_photos row and return the trek_photos id (used as photoId in public URLs). */
function insertJourneyPhoto(
entryId: number,
opts: { filePath?: string; assetId?: string; ownerId?: number } = {}
opts: { filePath?: string; assetId?: string; ownerId?: number } = {},
): number {
const provider = opts.assetId ? 'immich' : 'local';
const filePath = !opts.assetId ? (opts.filePath ?? '/photos/test.jpg') : null;
const trekResult = testDb.prepare(`
const trekResult = testDb
.prepare(
`
INSERT INTO trek_photos (provider, asset_id, file_path, owner_id, created_at)
VALUES (?, ?, ?, ?, ?)
`).run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
`,
)
.run(provider, opts.assetId ?? null, filePath, opts.ownerId ?? null, Date.now());
const trekId = trekResult.lastInsertRowid as number;
// Look up journey_id from entry so gallery row is keyed to the journey (not entry).
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as { journey_id: number };
const entryRow = testDb.prepare('SELECT journey_id FROM journey_entries WHERE id = ?').get(entryId) as {
journey_id: number;
};
const journeyId = entryRow.journey_id;
const now = Date.now();
testDb.prepare(`
testDb
.prepare(
`
INSERT OR IGNORE INTO journey_photos (journey_id, photo_id, caption, sort_order, created_at)
VALUES (?, ?, NULL, 0, ?)
`).run(journeyId, trekId, now);
`,
)
.run(journeyId, trekId, now);
const galleryRow = testDb.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?').get(journeyId, trekId) as { id: number };
const galleryRow = testDb
.prepare('SELECT id FROM journey_photos WHERE journey_id = ? AND photo_id = ?')
.get(journeyId, trekId) as { id: number };
testDb.prepare(`
testDb
.prepare(
`
INSERT OR IGNORE INTO journey_entry_photos (entry_id, journey_photo_id, sort_order, created_at)
VALUES (?, ?, 0, ?)
`).run(entryId, galleryRow.id, now);
`,
)
.run(entryId, galleryRow.id, now);
// Return trek_photos.id — this is p.photo_id in the public API response
// and the value the client sends to /api/public/journey/:token/photos/:photoId/:kind
@@ -265,7 +281,9 @@ describe('validateShareTokenForPhoto', () => {
// Pre-populate trek_photos to push the autoincrement higher
for (let i = 0; i < 5; i++) {
testDb.prepare(`INSERT INTO trek_photos (provider, asset_id, owner_id, created_at) VALUES ('immich', ?, ?, ?)`).run(`bulk-asset-${i}`, user.id, Date.now());
testDb
.prepare(`INSERT INTO trek_photos (provider, asset_id, owner_id, created_at) VALUES ('immich', ?, ?, ?)`)
.run(`bulk-asset-${i}`, user.id, Date.now());
}
// This trek_photos row gets a high id (e.g. 6) while journey_photos id will be 1
@@ -387,7 +405,8 @@ describe('getPublicJourney', () => {
entry_date: '2026-04-01',
});
// Set tags on the entry directly
testDb.prepare('UPDATE journey_entries SET tags = ? WHERE id = ?')
testDb
.prepare('UPDATE journey_entries SET tags = ? WHERE id = ?')
.run(JSON.stringify(['food', 'culture']), entry.id);
insertJourneyPhoto(entry.id, { filePath: '/photos/a.jpg' });
insertJourneyPhoto(entry.id, { filePath: '/photos/b.jpg' });
@@ -1,4 +1,3 @@
import { describe, it, expect } from 'vitest';
import {
buildCategoryNameLookup,
decodeUtf8WithWarning,
@@ -9,6 +8,8 @@ import {
sanitizeKmlDescription,
} from '../../../src/services/kmlImport';
import { describe, it, expect } from 'vitest';
describe('kmlImportUtils', () => {
it('sanitizes HTML descriptions with br to newline', () => {
const input = 'Line 1<br>Line <b>2</b> &amp; more';
+4 -4
View File
@@ -1,6 +1,8 @@
import { describe, it, expect, vi } from 'vitest';
import path from 'path';
import { unpackKmzToKml, KMZ_DECOMPRESSED_SIZE_LIMIT } from '../../../src/services/placeService';
import fs from 'fs';
import path from 'path';
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../../src/db/database', () => ({
db: { prepare: vi.fn() },
@@ -12,8 +14,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
import { unpackKmzToKml, KMZ_DECOMPRESSED_SIZE_LIMIT } from '../../../src/services/placeService';
const KMZ_FIXTURE = path.join(__dirname, '../../fixtures/test.kmz');
describe('unpackKmzToKml', () => {
File diff suppressed because it is too large Load Diff
@@ -2,6 +2,13 @@
* Unit tests for memories/helpersService MEM-HELPERS-001 to MEM-HELPERS-020.
* Covers mapDbError, getAlbumIdFromLink, pipeAsset error paths.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { mapDbError, getAlbumIdFromLink, pipeAsset } from '../../../src/services/memories/helpersService';
import { SsrfBlockedError } from '../../../src/utils/ssrfGuard';
import { createUser, createTrip } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ─────────────────────────────────────────────────────────────────
@@ -18,11 +25,15 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
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),
`,
)
.get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
@@ -42,7 +53,10 @@ const { mockSafeFetch } = vi.hoisted(() => ({
vi.mock('../../../src/utils/ssrfGuard', () => {
class SsrfBlockedError extends Error {
constructor(msg: string) { super(msg); this.name = 'SsrfBlockedError'; }
constructor(msg: string) {
super(msg);
this.name = 'SsrfBlockedError';
}
}
return {
safeFetch: mockSafeFetch,
@@ -51,13 +65,6 @@ vi.mock('../../../src/utils/ssrfGuard', () => {
};
});
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 { mapDbError, getAlbumIdFromLink, pipeAsset } from '../../../src/services/memories/helpersService';
import { SsrfBlockedError } from '../../../src/utils/ssrfGuard';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -129,9 +136,9 @@ describe('getAlbumIdFromLink', () => {
const trip = createTrip(testDb, user.id);
// Insert with auto-increment id (INTEGER PRIMARY KEY)
const ins = testDb.prepare(
'INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)'
).run(trip.id, user.id, 'immich', 'album-123', 'My Album');
const ins = testDb
.prepare('INSERT INTO trip_album_links (trip_id, user_id, provider, album_id, album_name) VALUES (?, ?, ?, ?, ?)')
.run(trip.id, user.id, 'immich', 'album-123', 'My Album');
const linkId = ins.lastInsertRowid;
const result = getAlbumIdFromLink(String(trip.id), String(linkId), user.id);
@@ -2,6 +2,20 @@
* Unit tests for memories/unifiedService MEM-UNIFIED-001 to MEM-UNIFIED-010.
* Covers error paths: access denied, disabled provider, no providers enabled.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
listTripPhotos,
listTripAlbumLinks,
addTripPhotos,
setTripPhotoSharing,
removeTripPhoto,
createTripAlbumLink,
removeAlbumLink,
} from '../../../src/services/memories/unifiedService';
import { createUser, createTrip } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ─────────────────────────────────────────────────────────────────
@@ -18,11 +32,15 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
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),
`,
)
.get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
@@ -40,20 +58,6 @@ vi.mock('../../../src/services/notificationService', () => ({
send: vi.fn().mockResolvedValue(undefined),
}));
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 {
listTripPhotos,
listTripAlbumLinks,
addTripPhotos,
setTripPhotoSharing,
removeTripPhoto,
createTripAlbumLink,
removeAlbumLink,
} from '../../../src/services/memories/unifiedService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -127,9 +131,11 @@ describe('addTripPhotos', () => {
const trip = createTrip(testDb, user.id);
// Insert a disabled provider
testDb.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run('disabled-prov', 'Disabled', 'Disabled provider', 'Image', 0, 99);
testDb
.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
)
.run('disabled-prov', 'Disabled', 'Disabled provider', 'Image', 0, 99);
const result = await addTripPhotos(
String(trip.id),
@@ -195,9 +201,11 @@ describe('createTripAlbumLink', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run('disabled-prov2', 'Disabled2', 'desc', 'Image', 0, 100);
testDb
.prepare(
'INSERT OR IGNORE INTO photo_providers (id, name, description, icon, enabled, sort_order) VALUES (?, ?, ?, ?, ?, ?)',
)
.run('disabled-prov2', 'Disabled2', 'desc', 'Image', 0, 100);
const result = createTripAlbumLink(String(trip.id), user.id, 'disabled-prov2', 'album-1', 'My Album');
expect(result.success).toBe(false);
+2 -2
View File
@@ -1,3 +1,5 @@
import { encryptMfaSecret, decryptMfaSecret } from '../../../src/services/mfaCrypto';
import { describe, it, expect, vi } from 'vitest';
// Inline factory to avoid vi.mock hoisting issue (no imported vars allowed)
@@ -7,8 +9,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
import { encryptMfaSecret, decryptMfaSecret } from '../../../src/services/mfaCrypto';
describe('mfaCrypto', () => {
const TOTP_SECRET = 'JBSWY3DPEHPK3PXP'; // typical base32 TOTP secret
+61 -30
View File
@@ -2,10 +2,11 @@
* Unit tests for migration 69 (normalized notification preferences).
* Covers MIGR-001 to MIGR-004.
*/
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import Database from 'better-sqlite3';
import { createTables } from '../../../src/db/schema';
import Database from 'better-sqlite3';
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
function buildFreshDb() {
const db = new Database(':memory:');
db.exec('PRAGMA journal_mode = WAL');
@@ -89,7 +90,7 @@ function runMigration69(db: ReturnType<typeof Database>): void {
packing_tagged: 'notify_packing_tagged',
};
const insert = db.prepare(
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)'
'INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, ?)',
);
const insertMany = db.transaction((rows: Array<[number, string, string, number]>) => {
for (const [userId, eventType, channel, enabled] of rows) {
@@ -124,9 +125,9 @@ describe('Migration 69 — normalized notification_channel_preferences', () => {
const db = setupPreMigration69Db();
runMigration69(db);
const table = db.prepare(
`SELECT name FROM sqlite_master WHERE type='table' AND name='notification_channel_preferences'`
).get();
const table = db
.prepare(`SELECT name FROM sqlite_master WHERE type='table' AND name='notification_channel_preferences'`)
.get();
expect(table).toBeDefined();
db.close();
});
@@ -135,27 +136,37 @@ describe('Migration 69 — normalized notification_channel_preferences', () => {
const db = setupPreMigration69Db();
// Create a user
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('testuser', 'hash', 'user')).lastInsertRowid as number;
const userId = db
.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)')
.run('testuser', 'hash', 'user').lastInsertRowid as number;
// Simulate user who has disabled trip_invite and booking_change email
db.prepare(`
db.prepare(
`
INSERT INTO notification_preferences
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
VALUES (?, 0, 0, 1, 1, 1, 1, 1, 1)
`).run(userId);
`,
).run(userId);
runMigration69(db);
const tripInviteEmail = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, 'trip_invite', 'email') as { enabled: number } | undefined;
const bookingEmail = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, 'booking_change', 'email') as { enabled: number } | undefined;
const reminderEmail = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, 'trip_reminder', 'email') as { enabled: number } | undefined;
const tripInviteEmail = db
.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
)
.get(userId, 'trip_invite', 'email') as { enabled: number } | undefined;
const bookingEmail = db
.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
)
.get(userId, 'booking_change', 'email') as { enabled: number } | undefined;
const reminderEmail = db
.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
)
.get(userId, 'trip_reminder', 'email') as { enabled: number } | undefined;
// Disabled events should have enabled=0 rows
expect(tripInviteEmail).toBeDefined();
@@ -171,30 +182,46 @@ describe('Migration 69 — normalized notification_channel_preferences', () => {
it('MIGR-003 — old notify_webhook=0 creates disabled webhook rows for all 7 events', () => {
const db = setupPreMigration69Db();
const userId = (db.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)').run('webhookuser', 'hash', 'user')).lastInsertRowid as number;
const userId = db
.prepare('INSERT INTO users (username, password, role) VALUES (?, ?, ?)')
.run('webhookuser', 'hash', 'user').lastInsertRowid as number;
// User has all email enabled but webhook disabled
db.prepare(`
db.prepare(
`
INSERT INTO notification_preferences
(user_id, notify_trip_invite, notify_booking_change, notify_trip_reminder,
notify_vacay_invite, notify_photos_shared, notify_collab_message, notify_packing_tagged, notify_webhook)
VALUES (?, 1, 1, 1, 1, 1, 1, 1, 0)
`).run(userId);
`,
).run(userId);
runMigration69(db);
const allEvents = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'];
const allEvents = [
'trip_invite',
'booking_change',
'trip_reminder',
'vacay_invite',
'photos_shared',
'collab_message',
'packing_tagged',
];
for (const eventType of allEvents) {
const row = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, eventType, 'webhook') as { enabled: number } | undefined;
const row = db
.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
)
.get(userId, eventType, 'webhook') as { enabled: number } | undefined;
expect(row).toBeDefined();
expect(row!.enabled).toBe(0);
// Email rows should NOT exist (all email was enabled → no row needed)
const emailRow = db.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(userId, eventType, 'email');
const emailRow = db
.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
)
.get(userId, eventType, 'email');
expect(emailRow).toBeUndefined();
}
@@ -209,7 +236,9 @@ describe('Migration 69 — normalized notification_channel_preferences', () => {
runMigration69(db);
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as
| { value: string }
| undefined;
expect(plural).toBeDefined();
expect(plural!.value).toBe('email');
@@ -226,7 +255,9 @@ describe('Migration 69 — normalized notification_channel_preferences', () => {
runMigration69(db);
// The existing notification_channels value should be preserved (INSERT OR IGNORE)
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as { value: string } | undefined;
const plural = db.prepare('SELECT value FROM app_settings WHERE key = ?').get('notification_channels') as
| { value: string }
| undefined;
expect(plural!.value).toBe('email,webhook');
db.close();
@@ -2,6 +2,27 @@
* Unit tests for notificationPreferencesService.
* Covers NPREF-001 to NPREF-021.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
isEnabledForEvent,
getPreferencesMatrix,
setPreferences,
setAdminPreferences,
getAdminGlobalPref,
getActiveChannels,
getAvailableChannels,
isWebhookConfigured,
} from '../../../src/services/notificationPreferencesService';
import {
createUser,
createAdmin,
setAppSetting,
setNotificationChannels,
disableNotificationPref,
} from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -32,21 +53,6 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({
encrypt_api_key: (v: string) => v,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
import {
isEnabledForEvent,
getPreferencesMatrix,
setPreferences,
setAdminPreferences,
getAdminGlobalPref,
getActiveChannels,
getAvailableChannels,
isWebhookConfigured,
} from '../../../src/services/notificationPreferencesService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -72,9 +78,11 @@ describe('isEnabledForEvent', () => {
it('NPREF-002 — returns true when row exists with enabled=1', () => {
const { user } = createUser(testDb);
testDb.prepare(
'INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 1)'
).run(user.id, 'trip_invite', 'email');
testDb
.prepare(
'INSERT INTO notification_channel_preferences (user_id, event_type, channel, enabled) VALUES (?, ?, ?, 1)',
)
.run(user.id, 'trip_invite', 'email');
expect(isEnabledForEvent(user.id, 'trip_invite', 'email')).toBe(true);
});
@@ -173,9 +181,11 @@ describe('setPreferences', () => {
it('NPREF-012 — disabling a preference inserts a row with enabled=0', () => {
const { user } = createUser(testDb);
setPreferences(user.id, { trip_invite: { email: false } });
const row = testDb.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(user.id, 'trip_invite', 'email') as { enabled: number } | undefined;
const row = testDb
.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
)
.get(user.id, 'trip_invite', 'email') as { enabled: number } | undefined;
expect(row).toBeDefined();
expect(row!.enabled).toBe(0);
});
@@ -186,9 +196,11 @@ describe('setPreferences', () => {
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
// Then re-enable
setPreferences(user.id, { trip_invite: { email: true } });
const row = testDb.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(user.id, 'trip_invite', 'email');
const row = testDb
.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
)
.get(user.id, 'trip_invite', 'email');
// Row should be deleted — default is enabled
expect(row).toBeUndefined();
});
@@ -204,9 +216,11 @@ describe('setPreferences', () => {
expect(isEnabledForEvent(user.id, 'trip_invite', 'webhook')).toBe(false);
expect(isEnabledForEvent(user.id, 'booking_change', 'email')).toBe(false);
// trip_reminder webhook was set to true → no row, default enabled
const row = testDb.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(user.id, 'trip_reminder', 'webhook');
const row = testDb
.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
)
.get(user.id, 'trip_reminder', 'webhook');
expect(row).toBeUndefined();
});
});
@@ -279,20 +293,26 @@ describe('setAdminPreferences', () => {
const { user } = createAdmin(testDb);
setAdminPreferences(user.id, { version_available: { email: false } });
expect(getAdminGlobalPref('version_available', 'email')).toBe(false);
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
const row = testDb
.prepare('SELECT value FROM app_settings WHERE key = ?')
.get('admin_notif_pref_version_available_email') as { value: string } | undefined;
expect(row?.value).toBe('0');
});
it('NPREF-023 — disabling inapp for version_available stores per-user row in notification_channel_preferences', () => {
const { user } = createAdmin(testDb);
setAdminPreferences(user.id, { version_available: { inapp: false } });
const row = testDb.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(user.id, 'version_available', 'inapp') as { enabled: number } | undefined;
const row = testDb
.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
)
.get(user.id, 'version_available', 'inapp') as { enabled: number } | undefined;
expect(row).toBeDefined();
expect(row!.enabled).toBe(0);
// Global app_settings should NOT have an inapp key
const globalRow = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_inapp');
const globalRow = testDb
.prepare('SELECT value FROM app_settings WHERE key = ?')
.get('admin_notif_pref_version_available_inapp');
expect(globalRow).toBeUndefined();
});
@@ -302,9 +322,11 @@ describe('setAdminPreferences', () => {
disableNotificationPref(testDb, user.id, 'version_available', 'inapp');
// Then re-enable via setAdminPreferences
setAdminPreferences(user.id, { version_available: { inapp: true } });
const row = testDb.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?'
).get(user.id, 'version_available', 'inapp');
const row = testDb
.prepare(
'SELECT enabled FROM notification_channel_preferences WHERE user_id = ? AND event_type = ? AND channel = ?',
)
.get(user.id, 'version_available', 'inapp');
expect(row).toBeUndefined();
});
@@ -314,7 +336,9 @@ describe('setAdminPreferences', () => {
setAdminPreferences(user.id, { version_available: { email: false } });
setAdminPreferences(user.id, { version_available: { email: true } });
expect(getAdminGlobalPref('version_available', 'email')).toBe(true);
const row = testDb.prepare("SELECT value FROM app_settings WHERE key = ?").get('admin_notif_pref_version_available_email') as { value: string } | undefined;
const row = testDb
.prepare('SELECT value FROM app_settings WHERE key = ?')
.get('admin_notif_pref_version_available_email') as { value: string } | undefined;
expect(row?.value).toBe('1');
});
});
@@ -2,6 +2,18 @@
* Unit tests for the unified notificationService.send().
* Covers NSVC-001 to NSVC-014.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { send } from '../../../src/services/notificationService';
import {
createUser,
createAdmin,
setAppSetting,
setNotificationChannels,
disableNotificationPref,
} from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -54,12 +66,6 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
createPinnedDispatcher: vi.fn(() => ({})),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createAdmin, setAppSetting, setNotificationChannels, disableNotificationPref } from '../../helpers/factories';
import { send } from '../../../src/services/notificationService';
// ── Helpers ────────────────────────────────────────────────────────────────
function setSmtp(): void {
@@ -124,9 +130,16 @@ describe('send() — multi-channel dispatch', () => {
setNotificationChannels(testDb, 'email,webhook');
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)
.lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) },
});
expect(sendMailMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledTimes(1);
@@ -140,9 +153,16 @@ describe('send() — multi-channel dispatch', () => {
setUserWebhookUrl(user.id);
setNotificationChannels(testDb, 'none');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Rome', user.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Rome', user.id)
.lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) },
});
expect(sendMailMock).not.toHaveBeenCalled();
expect(fetchMock).not.toHaveBeenCalled();
@@ -156,9 +176,16 @@ describe('send() — multi-channel dispatch', () => {
setNotificationChannels(testDb, 'email');
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Berlin', user.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Berlin', user.id)
.lastInsertRowid as number;
await send({ event: 'booking_change', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Berlin', actor: 'Bob', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
await send({
event: 'booking_change',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Berlin', actor: 'Bob', booking: 'Hotel', type: 'hotel', tripId: String(tripId) },
});
expect(sendMailMock).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
@@ -177,9 +204,16 @@ describe('send() — per-user preference filtering', () => {
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)
.lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) },
});
expect(sendMailMock).not.toHaveBeenCalled();
// in-app still fires
@@ -191,9 +225,16 @@ describe('send() — per-user preference filtering', () => {
setNotificationChannels(testDb, 'none');
disableNotificationPref(testDb, user.id, 'collab_message', 'inapp');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)
.lastInsertRowid as number;
await send({ event: 'collab_message', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) } });
await send({
event: 'collab_message',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Trip', actor: 'Alice', tripId: String(tripId) },
});
expect(broadcastMock).not.toHaveBeenCalled();
expect(countAllNotifications()).toBe(0);
@@ -207,9 +248,16 @@ describe('send() — per-user preference filtering', () => {
testDb.prepare('UPDATE users SET email = ? WHERE id = ?').run('recipient@test.com', user.id);
disableNotificationPref(testDb, user.id, 'trip_invite', 'email');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)
.lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) },
});
expect(sendMailMock).not.toHaveBeenCalled();
expect(fetchMock).toHaveBeenCalledTimes(1);
@@ -228,16 +276,25 @@ describe('send() — recipient resolution', () => {
const { user: actor } = createUser(testDb);
setNotificationChannels(testDb, 'none');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', owner.id)
.lastInsertRowid as number;
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member1.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, member2.id);
testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(tripId, actor.id);
await send({ event: 'booking_change', actorId: actor.id, scope: 'trip', targetId: tripId, params: { trip: 'Trip', actor: 'Actor', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
await send({
event: 'booking_change',
actorId: actor.id,
scope: 'trip',
targetId: tripId,
params: { trip: 'Trip', actor: 'Actor', booking: 'Hotel', type: 'hotel', tripId: String(tripId) },
});
// Owner, member1, member2 get it; actor is excluded
expect(countAllNotifications()).toBe(3);
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
const recipients = (
testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]
).map((r) => r.recipient_id);
expect(recipients).toContain(owner.id);
expect(recipients).toContain(member1.id);
expect(recipients).toContain(member2.id);
@@ -249,7 +306,13 @@ describe('send() — recipient resolution', () => {
const { user: other } = createUser(testDb);
setNotificationChannels(testDb, 'none');
await send({ event: 'vacay_invite', actorId: other.id, scope: 'user', targetId: target.id, params: { actor: 'other@test.com', planId: '42' } });
await send({
event: 'vacay_invite',
actorId: other.id,
scope: 'user',
targetId: target.id,
params: { actor: 'other@test.com', planId: '42' },
});
expect(countAllNotifications()).toBe(1);
const notif = testDb.prepare('SELECT recipient_id FROM notifications LIMIT 1').get() as { recipient_id: number };
@@ -262,10 +325,18 @@ describe('send() — recipient resolution', () => {
createUser(testDb); // regular user — should NOT receive
setNotificationChannels(testDb, 'none');
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
await send({
event: 'version_available',
actorId: null,
scope: 'admin',
targetId: 0,
params: { version: '2.0.0' },
});
expect(countAllNotifications()).toBe(2);
const recipients = (testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]).map(r => r.recipient_id);
const recipients = (
testDb.prepare('SELECT recipient_id FROM notifications ORDER BY recipient_id').all() as { recipient_id: number }[]
).map((r) => r.recipient_id);
expect(recipients).toContain(admin1.id);
expect(recipients).toContain(admin2.id);
});
@@ -275,10 +346,16 @@ describe('send() — recipient resolution', () => {
setAdminWebhookUrl();
setNotificationChannels(testDb, 'none');
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '2.0.0' } });
await send({
event: 'version_available',
actorId: null,
scope: 'admin',
targetId: 0,
params: { version: '2.0.0' },
});
// Wait for fire-and-forget admin webhook
await new Promise(r => setTimeout(r, 10));
await new Promise((r) => setTimeout(r, 10));
expect(fetchMock).toHaveBeenCalledTimes(1);
const callUrl = fetchMock.mock.calls[0][0];
expect(callUrl).toBe('https://hooks.test.com/admin-webhook');
@@ -288,9 +365,16 @@ describe('send() — recipient resolution', () => {
// Trip with no members, sending as the trip owner (actor excluded from trip scope)
const { user: owner } = createUser(testDb);
setNotificationChannels(testDb, 'none');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Solo', owner.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Solo', owner.id)
.lastInsertRowid as number;
await send({ event: 'booking_change', actorId: owner.id, scope: 'trip', targetId: tripId, params: { trip: 'Solo', actor: 'owner@test.com', booking: 'Hotel', type: 'hotel', tripId: String(tripId) } });
await send({
event: 'booking_change',
actorId: owner.id,
scope: 'trip',
targetId: tripId,
params: { trip: 'Solo', actor: 'owner@test.com', booking: 'Hotel', type: 'hotel', tripId: String(tripId) },
});
expect(countAllNotifications()).toBe(0);
expect(broadcastMock).not.toHaveBeenCalled();
@@ -306,7 +390,13 @@ describe('send() — in-app notification content', () => {
const { user } = createUser(testDb);
setNotificationChannels(testDb, 'none');
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '42' } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '42' },
});
const notifs = getInAppNotifications(user.id);
expect(notifs.length).toBe(1);
@@ -334,7 +424,13 @@ describe('send() — in-app notification content', () => {
const { user: admin } = createAdmin(testDb);
setNotificationChannels(testDb, 'none');
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '9.9.9' } });
await send({
event: 'version_available',
actorId: null,
scope: 'admin',
targetId: 0,
params: { version: '9.9.9' },
});
const notifs = getInAppNotifications(admin.id);
expect(notifs.length).toBe(1);
@@ -356,9 +452,16 @@ describe('send() — email/webhook links', () => {
// Set user language to French
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'language', 'fr')").run(user.id);
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Paris', user.id)
.lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) },
});
expect(sendMailMock).toHaveBeenCalledTimes(1);
const mailArgs = sendMailMock.mock.calls[0][0];
@@ -371,7 +474,13 @@ describe('send() — email/webhook links', () => {
setUserWebhookUrl(user.id, 'https://hooks.test.com/generic-webhook');
setNotificationChannels(testDb, 'webhook');
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '55' } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '55' },
});
expect(fetchMock).toHaveBeenCalledTimes(1);
const body = JSON.parse(fetchMock.mock.calls[0][1].body);
@@ -428,9 +537,16 @@ describe('send() — channel failure resilience', () => {
// Make email throw
sendMailMock.mockRejectedValueOnce(new Error('SMTP connection refused'));
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)
.lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) },
});
// In-app and webhook still fire despite email failure
expect(broadcastMock).toHaveBeenCalledTimes(1);
@@ -448,9 +564,16 @@ describe('send() — channel failure resilience', () => {
// Make webhook throw
fetchMock.mockRejectedValueOnce(new Error('Network error'));
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Trip', user.id)
.lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Trip', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) },
});
// In-app and email still fire despite webhook failure
expect(broadcastMock).toHaveBeenCalledTimes(1);
@@ -462,7 +585,9 @@ describe('send() — channel failure resilience', () => {
// ── Ntfy dispatch ─────────────────────────────────────────────────────────────
function setUserNtfyTopic(userId: number, topic = 'my-trek-topic'): void {
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', ?)").run(userId, topic);
testDb
.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', ?)")
.run(userId, topic);
}
function setAdminNtfyTopic(topic = 'trek-admin-alerts'): void {
@@ -478,9 +603,16 @@ describe('send() — ntfy channel dispatch', () => {
const { user } = createUser(testDb);
setUserNtfyTopic(user.id);
setNotificationChannels(testDb, 'ntfy');
const tripId = (testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Tokyo', user.id)).lastInsertRowid as number;
const tripId = testDb.prepare('INSERT INTO trips (title, user_id) VALUES (?, ?)').run('Tokyo', user.id)
.lastInsertRowid as number;
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Tokyo', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Tokyo', actor: 'Alice', invitee: 'Bob', tripId: String(tripId) },
});
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
expect(ntfyCalls.length).toBeGreaterThan(0);
@@ -495,7 +627,13 @@ describe('send() — ntfy channel dispatch', () => {
setNotificationChannels(testDb, 'none');
fetchMock.mockClear();
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Paris', actor: 'Alice', invitee: 'Bob', tripId: '1' },
});
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
expect(ntfyCalls.length).toBe(0);
@@ -507,7 +645,13 @@ describe('send() — ntfy channel dispatch', () => {
// No ntfy_topic set, but no admin_ntfy_server either — resolveNtfyUrl returns null
fetchMock.mockClear();
await send({ event: 'trip_invite', actorId: null, scope: 'user', targetId: user.id, params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' } });
await send({
event: 'trip_invite',
actorId: null,
scope: 'user',
targetId: user.id,
params: { trip: 'Rome', actor: 'Alice', invitee: 'Bob', tripId: '1' },
});
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
expect(ntfyCalls.length).toBe(0);
@@ -519,7 +663,13 @@ describe('send() — ntfy channel dispatch', () => {
setNotificationChannels(testDb, 'none');
fetchMock.mockClear();
await send({ event: 'version_available', actorId: null, scope: 'admin', targetId: 0, params: { version: '3.0.0' } });
await send({
event: 'version_available',
actorId: null,
scope: 'admin',
targetId: 0,
params: { version: '3.0.0' },
});
const ntfyCalls = fetchMock.mock.calls.filter(([url]: [string]) => url.includes('ntfy.sh'));
expect(ntfyCalls.length).toBeGreaterThan(0);
@@ -1,3 +1,15 @@
import { logError } from '../../../src/services/auditLog';
import {
getEventText,
buildEmailHtml,
buildWebhookBody,
sendWebhook,
sendNtfy,
resolveNtfyUrl,
type NtfyConfig,
} from '../../../src/services/notifications';
import { checkSsrf } from '../../../src/utils/ssrfGuard';
import { describe, it, expect, vi, afterEach, afterAll, beforeEach } from 'vitest';
vi.mock('../../../src/db/database', () => ({
@@ -24,10 +36,6 @@ vi.mock('../../../src/utils/ssrfGuard', () => ({
createPinnedDispatcher: vi.fn(() => ({})),
}));
import { getEventText, buildEmailHtml, buildWebhookBody, sendWebhook, sendNtfy, resolveNtfyUrl, type NtfyConfig } from '../../../src/services/notifications';
import { checkSsrf } from '../../../src/utils/ssrfGuard';
import { logError } from '../../../src/services/auditLog';
afterEach(() => {
vi.unstubAllEnvs();
});
@@ -76,7 +84,15 @@ describe('getEventText', () => {
});
it('all 7 event types produce non-empty title and body in English', () => {
const events = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'] as const;
const events = [
'trip_invite',
'booking_change',
'trip_reminder',
'vacay_invite',
'photos_shared',
'collab_message',
'packing_tagged',
] as const;
for (const event of events) {
const result = getEventText('en', event, params);
expect(result.title, `title for ${event}`).toBeTruthy();
@@ -85,7 +101,15 @@ describe('getEventText', () => {
});
it('all 7 event types produce non-empty title and body in German', () => {
const events = ['trip_invite', 'booking_change', 'trip_reminder', 'vacay_invite', 'photos_shared', 'collab_message', 'packing_tagged'] as const;
const events = [
'trip_invite',
'booking_change',
'trip_reminder',
'vacay_invite',
'photos_shared',
'collab_message',
'packing_tagged',
] as const;
for (const event of events) {
const result = getEventText('de', event, params);
expect(result.title, `de title for ${event}`).toBeTruthy();
@@ -264,7 +288,9 @@ describe('sendWebhook SSRF protection (SEC-017)', () => {
it('blocks loopback address and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
allowed: false,
isPrivate: true,
resolvedIp: '127.0.0.1',
error: 'Requests to loopback and link-local addresses are not allowed',
});
@@ -275,7 +301,9 @@ describe('sendWebhook SSRF protection (SEC-017)', () => {
it('blocks cloud metadata endpoint (169.254.169.254) and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '169.254.169.254',
allowed: false,
isPrivate: true,
resolvedIp: '169.254.169.254',
error: 'Requests to loopback and link-local addresses are not allowed',
});
@@ -286,7 +314,9 @@ describe('sendWebhook SSRF protection (SEC-017)', () => {
it('blocks private network addresses and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '192.168.1.1',
allowed: false,
isPrivate: true,
resolvedIp: '192.168.1.1',
error: 'Requests to private/internal network addresses are not allowed',
});
@@ -297,7 +327,8 @@ describe('sendWebhook SSRF protection (SEC-017)', () => {
it('blocks non-HTTP protocols', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: false,
allowed: false,
isPrivate: false,
error: 'Only HTTP and HTTPS URLs are allowed',
});
@@ -309,7 +340,9 @@ describe('sendWebhook SSRF protection (SEC-017)', () => {
const mockFetch = globalThis.fetch as unknown as ReturnType<typeof vi.fn>;
mockFetch.mockClear();
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '127.0.0.1',
allowed: false,
isPrivate: true,
resolvedIp: '127.0.0.1',
error: 'blocked',
});
@@ -417,7 +450,9 @@ describe('sendNtfy', () => {
it('NTFY-005 — SSRF guard blocks private URL and returns false', async () => {
vi.mocked(checkSsrf).mockResolvedValueOnce({
allowed: false, isPrivate: true, resolvedIp: '192.168.1.1',
allowed: false,
isPrivate: true,
resolvedIp: '192.168.1.1',
error: 'Requests to private/internal network addresses are not allowed',
});
+111 -93
View File
@@ -1,57 +1,9 @@
/**
* Unit tests for server/src/services/oauthService.ts.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
import crypto from 'crypto';
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, t.user_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/services/apiKeyCrypto', () => ({
encrypt_api_key: (v: string) => v,
decrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: (v: string) => v,
}));
vi.mock('../../../src/mcp/sessionManager', () => ({ revokeUserSessions: vi.fn(), revokeUserSessionsForClient: vi.fn(), sessions: new Map() }));
vi.mock('../../../src/demo/demo-reset', () => ({ saveBaseline: vi.fn() }));
vi.mock('../../../src/services/adminService', () => ({
isAddonEnabled: vi.fn().mockReturnValue(true),
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
// PKCE helper — generates a valid code_verifier + code_challenge pair (RFC 7636)
function makePkce() {
const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); // 43 chars
return { verifier, challenge };
}
import { createTables } from '../../../src/db/schema';
import { isAddonEnabled } from '../../../src/services/adminService';
import {
createOAuthClient,
listOAuthClients,
@@ -72,7 +24,63 @@ import {
getConsent,
isConsentSufficient,
} from '../../../src/services/oauthService';
import { isAddonEnabled } from '../../../src/services/adminService';
import { createUser } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import crypto from 'crypto';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
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, t.user_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/services/apiKeyCrypto', () => ({
encrypt_api_key: (v: string) => v,
decrypt_api_key: (v: string) => v,
maybe_encrypt_api_key: (v: string) => v,
}));
vi.mock('../../../src/mcp/sessionManager', () => ({
revokeUserSessions: vi.fn(),
revokeUserSessionsForClient: vi.fn(),
sessions: new Map(),
}));
vi.mock('../../../src/demo/demo-reset', () => ({ saveBaseline: vi.fn() }));
vi.mock('../../../src/services/adminService', () => ({
isAddonEnabled: vi.fn().mockReturnValue(true),
getCollabFeatures: vi.fn().mockReturnValue({ chat: true, notes: true, polls: true, whatsnext: true }),
}));
// PKCE helper — generates a valid code_verifier + code_challenge pair (RFC 7636)
function makePkce() {
const verifier = crypto.randomBytes(32).toString('base64url'); // 43 chars
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url'); // 43 chars
return { verifier, challenge };
}
beforeAll(() => {
createTables(testDb);
@@ -98,7 +106,7 @@ afterAll(() => {
function makeClient(
userId: number,
overrides: Partial<{ name: string; redirectUris: string[]; scopes: string[] }> = {}
overrides: Partial<{ name: string; redirectUris: string[]; scopes: string[] }> = {},
) {
return createOAuthClient(
userId,
@@ -125,9 +133,7 @@ describe('createOAuthClient', () => {
it('client_id is a UUID', () => {
const { user } = createUser(testDb);
const result = makeClient(user.id);
expect(result.client!.client_id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
);
expect(result.client!.client_id).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});
it('returns 400 error if name is empty', () => {
@@ -226,7 +232,11 @@ describe('listOAuthClients', () => {
it('returns created clients with redirect_uris and allowed_scopes as arrays', () => {
const { user } = createUser(testDb);
makeClient(user.id, { name: 'Client A', redirectUris: ['https://a.com/cb'], scopes: ['trips:read', 'budget:read'] });
makeClient(user.id, {
name: 'Client A',
redirectUris: ['https://a.com/cb'],
scopes: ['trips:read', 'budget:read'],
});
const clients = listOAuthClients(user.id);
expect(clients).toHaveLength(1);
expect(clients[0].name).toBe('Client A');
@@ -531,14 +541,16 @@ describe('validateAuthorizeRequest', () => {
// Use a proper 43-char S256 code_challenge to pass H1 format validation
const { challenge: VALID_CHALLENGE } = makePkce();
function makeParams(overrides: Partial<{
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
code_challenge: string;
code_challenge_method: string;
}> = {}) {
function makeParams(
overrides: Partial<{
response_type: string;
client_id: string;
redirect_uri: string;
scope: string;
code_challenge: string;
code_challenge_method: string;
}> = {},
) {
return {
response_type: 'code',
client_id: '',
@@ -585,7 +597,7 @@ describe('validateAuthorizeRequest', () => {
const result = validateAuthorizeRequest(
makeParams({ client_id: clientId, redirect_uri: 'https://evil.com/callback' }),
user.id
user.id,
);
expect(result.valid).toBe(false);
expect(result.error).toBe('invalid_redirect_uri');
@@ -596,10 +608,7 @@ describe('validateAuthorizeRequest', () => {
const created = makeClient(user.id, { scopes: ['trips:read'] });
const clientId = created.client!.client_id as string;
const result = validateAuthorizeRequest(
makeParams({ client_id: clientId, scope: 'budget:write' }),
user.id
);
const result = validateAuthorizeRequest(makeParams({ client_id: clientId, scope: 'budget:write' }), user.id);
expect(result.valid).toBe(false);
expect(result.error).toBe('invalid_scope');
});
@@ -879,14 +888,17 @@ describe('validateAuthorizeRequest — PKCE format (H1)', () => {
const created = makeClient(user.id);
const clientId = created.client!.client_id as string;
const result = validateAuthorizeRequest({
response_type: 'code',
client_id: clientId,
redirect_uri: 'https://example.com/callback',
scope: 'trips:read',
code_challenge: 'tooshort',
code_challenge_method: 'S256',
}, user.id);
const result = validateAuthorizeRequest(
{
response_type: 'code',
client_id: clientId,
redirect_uri: 'https://example.com/callback',
scope: 'trips:read',
code_challenge: 'tooshort',
code_challenge_method: 'S256',
},
user.id,
);
expect(result.valid).toBe(false);
expect(result.error).toBe('invalid_request');
});
@@ -898,14 +910,17 @@ describe('validateAuthorizeRequest — PKCE format (H1)', () => {
// 43 chars but includes '=' which is not base64url
const badChallenge = '='.repeat(43);
const result = validateAuthorizeRequest({
response_type: 'code',
client_id: clientId,
redirect_uri: 'https://example.com/callback',
scope: 'trips:read',
code_challenge: badChallenge,
code_challenge_method: 'S256',
}, user.id);
const result = validateAuthorizeRequest(
{
response_type: 'code',
client_id: clientId,
redirect_uri: 'https://example.com/callback',
scope: 'trips:read',
code_challenge: badChallenge,
code_challenge_method: 'S256',
},
user.id,
);
expect(result.valid).toBe(false);
expect(result.error).toBe('invalid_request');
});
@@ -922,14 +937,17 @@ describe('validateAuthorizeRequest — unauthenticated strips client info (H3)',
const clientId = created.client!.client_id as string;
const { challenge } = makePkce();
const result = validateAuthorizeRequest({
response_type: 'code',
client_id: clientId,
redirect_uri: 'https://example.com/callback',
scope: 'trips:read',
code_challenge: challenge,
code_challenge_method: 'S256',
}, null /* unauthenticated */);
const result = validateAuthorizeRequest(
{
response_type: 'code',
client_id: clientId,
redirect_uri: 'https://example.com/callback',
scope: 'trips:read',
code_challenge: challenge,
code_challenge_method: 'S256',
},
null /* unauthenticated */,
);
expect(result.valid).toBe(true);
expect(result.loginRequired).toBe(true);
+107 -98
View File
@@ -3,9 +3,25 @@
* Covers state management, auth codes, role resolution, findOrCreateUser,
* discover caching, and the ReDoS-sensitive issuer trailing-slash regex.
*/
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
createState,
consumeState,
createAuthCode,
consumeAuthCode,
resolveOidcRole,
frontendUrl,
findOrCreateUser,
discover,
verifyIdToken,
} from '../../../src/services/oidcService';
import { createUser } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { generateKeyPairSync } from 'crypto';
import jwtLib from 'jsonwebtoken';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -21,11 +37,15 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
db
.prepare(
`
SELECT t.id, t.user_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),
`,
)
.get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
@@ -39,22 +59,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import {
createState,
consumeState,
createAuthCode,
consumeAuthCode,
resolveOidcRole,
frontendUrl,
findOrCreateUser,
discover,
verifyIdToken,
} from '../../../src/services/oidcService';
const MOCK_CONFIG = {
issuer: 'https://oidc.example.com',
clientId: 'client-id',
@@ -204,10 +208,13 @@ describe('discover', () => {
token_endpoint: 'https://oidc.example.com/token',
userinfo_endpoint: 'https://oidc.example.com/userinfo',
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => doc,
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => doc,
}),
);
// Use unique issuer to bypass module-level cache from other tests
const result = await discover('https://unique-1.example.com');
@@ -249,9 +256,7 @@ describe('discover', () => {
};
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
await expect(discover('https://unique-2.example.com')).rejects.toThrow(
'OIDC discovery issuer mismatch',
);
await expect(discover('https://unique-2.example.com')).rejects.toThrow('OIDC discovery issuer mismatch');
});
it('OIDC-SVC-039: trailing-slash-only mismatch with explicit discoveryUrl does not warn', async () => {
@@ -264,10 +269,7 @@ describe('discover', () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => doc }));
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
await discover(
'https://auth.example.com',
'https://auth.example.com/.well-known/openid-configuration',
);
await discover('https://auth.example.com', 'https://auth.example.com/.well-known/openid-configuration');
expect(warnSpy).not.toHaveBeenCalled();
warnSpy.mockRestore();
@@ -295,13 +297,11 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-020: finds existing user by oidc_sub', () => {
const { user } = createUser(testDb, { email: 'alice@example.com' });
// Link the sub manually
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
testDb
.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-alice-123', MOCK_CONFIG.issuer, user.id);
const result = findOrCreateUser(
{ sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' },
MOCK_CONFIG
);
const result = findOrCreateUser({ sub: 'sub-alice-123', email: 'alice@example.com', name: 'Alice' }, MOCK_CONFIG);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.id).toBe(user.id);
});
@@ -309,19 +309,13 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-021: finds existing user by email when no sub match', () => {
const { user } = createUser(testDb, { email: 'bob@example.com' });
const result = findOrCreateUser(
{ sub: 'sub-bob-new', email: 'bob@example.com', name: 'Bob' },
MOCK_CONFIG
);
const result = findOrCreateUser({ sub: 'sub-bob-new', email: 'bob@example.com', name: 'Bob' }, MOCK_CONFIG);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.id).toBe(user.id);
});
it('OIDC-SVC-022: creates new user when registration is open', () => {
const result = findOrCreateUser(
{ sub: 'sub-new-1', email: 'newuser@example.com', name: 'New User' },
MOCK_CONFIG
);
const result = findOrCreateUser({ sub: 'sub-new-1', email: 'newuser@example.com', name: 'New User' }, MOCK_CONFIG);
expect('user' in result).toBe(true);
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get();
expect(newUser).toBeDefined();
@@ -329,10 +323,7 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-023: first user gets admin role', () => {
// DB is empty after resetTestDb
const result = findOrCreateUser(
{ sub: 'sub-first', email: 'first@example.com', name: 'First' },
MOCK_CONFIG
);
const result = findOrCreateUser({ sub: 'sub-first', email: 'first@example.com', name: 'First' }, MOCK_CONFIG);
expect('user' in result).toBe(true);
expect((result as { user: any }).user.role).toBe('admin');
});
@@ -341,10 +332,7 @@ describe('findOrCreateUser', () => {
createUser(testDb, { email: 'existing@example.com' });
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
const result = findOrCreateUser(
{ sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' },
MOCK_CONFIG
);
const result = findOrCreateUser({ sub: 'sub-blocked', email: 'blocked@example.com', name: 'Blocked' }, MOCK_CONFIG);
expect('error' in result).toBe(true);
expect((result as { error: string }).error).toBe('registration_disabled');
});
@@ -354,10 +342,7 @@ describe('findOrCreateUser', () => {
// Ensure no oidc_sub set
testDb.prepare('UPDATE users SET oidc_sub = NULL, oidc_issuer = NULL WHERE id = ?').run(user.id);
findOrCreateUser(
{ sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie' },
MOCK_CONFIG
);
findOrCreateUser({ sub: 'sub-charlie-linked', email: 'charlie@example.com', name: 'Charlie' }, MOCK_CONFIG);
const updated = testDb.prepare('SELECT oidc_sub FROM users WHERE id = ?').get(user.id) as any;
expect(updated.oidc_sub).toBe('sub-charlie-linked');
@@ -366,14 +351,15 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-026: existing user role is updated when OIDC claim mapping changes it', () => {
const { user } = createUser(testDb, { email: 'diana@example.com', role: 'user' });
// Link oidc_sub manually so the user is found by sub lookup
testDb.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
testDb
.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?')
.run('sub-diana-role', MOCK_CONFIG.issuer, user.id);
process.env.OIDC_ADMIN_VALUE = 'admins';
const result = findOrCreateUser(
{ sub: 'sub-diana-role', email: 'diana@example.com', name: 'Diana', groups: ['admins'] },
MOCK_CONFIG
MOCK_CONFIG,
);
expect('user' in result).toBe(true);
@@ -385,14 +371,14 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-027: new user with valid invite token increments used_count', () => {
const { user: creator } = createUser(testDb, { email: 'creator@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)"
).run(creator.id);
testDb
.prepare("INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-valid', 5, 0, ?)")
.run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-invite-user', email: 'invitee@example.com', name: 'Invitee' },
MOCK_CONFIG,
'tok-valid'
'tok-valid',
);
expect('user' in result).toBe(true);
@@ -403,14 +389,16 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-028: new user with expired invite token is created but invite is ignored', () => {
const { user: creator } = createUser(testDb, { email: 'creator2@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)"
).run(creator.id);
testDb
.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, expires_at, created_by) VALUES ('tok-expired', 5, 0, '2000-01-01T00:00:00.000Z', ?)",
)
.run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-expired-invite', email: 'expired-invitee@example.com', name: 'ExpiredInvitee' },
MOCK_CONFIG,
'tok-expired'
'tok-expired',
);
// User is still created because open registration is allowed
@@ -425,14 +413,14 @@ describe('findOrCreateUser', () => {
it('OIDC-SVC-029: new user with max_uses exceeded invite token is created but invite is ignored', () => {
const { user: creator } = createUser(testDb, { email: 'creator3@example.com' });
testDb.prepare(
"INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)"
).run(creator.id);
testDb
.prepare("INSERT INTO invite_tokens (token, max_uses, used_count, created_by) VALUES ('tok-full', 1, 1, ?)")
.run(creator.id);
const result = findOrCreateUser(
{ sub: 'sub-full-invite', email: 'full-invitee@example.com', name: 'FullInvitee' },
MOCK_CONFIG,
'tok-full'
'tok-full',
);
// User is still created because open registration is allowed
@@ -457,14 +445,23 @@ describe('exchangeCodeForToken', () => {
const { exchangeCodeForToken } = await import('../../../src/services/oidcService');
const mockTokenData = { access_token: 'tok', token_type: 'Bearer' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockTokenData,
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
status: 200,
json: async () => mockTokenData,
}),
);
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
const result = await exchangeCodeForToken(doc, 'auth-code-123', 'https://app/callback', 'client-id', 'client-secret');
const result = await exchangeCodeForToken(
doc,
'auth-code-123',
'https://app/callback',
'client-id',
'client-secret',
);
expect(result.access_token).toBe('tok');
expect(result._ok).toBe(true);
@@ -478,11 +475,14 @@ describe('exchangeCodeForToken', () => {
it('OIDC-SVC-031: reflects _ok=false when provider returns error status', async () => {
const { exchangeCodeForToken } = await import('../../../src/services/oidcService');
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'invalid_grant' }),
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: false,
status: 400,
json: async () => ({ error: 'invalid_grant' }),
}),
);
const doc = { token_endpoint: 'https://oidc.example.com/token' } as any;
const result = await exchangeCodeForToken(doc, 'bad-code', 'https://app/callback', 'c', 's');
@@ -503,9 +503,12 @@ describe('getUserInfo', () => {
const { getUserInfo } = await import('../../../src/services/oidcService');
const userInfoData = { sub: 'user-sub', email: 'user@example.com', name: 'User Name' };
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
json: async () => userInfoData,
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
json: async () => userInfoData,
}),
);
const result = await getUserInfo('https://oidc.example.com/userinfo', 'access-token-123');
@@ -527,23 +530,29 @@ describe('verifyIdToken', () => {
const JWKS_URI = 'https://auth.example.com/.well-known/jwks.json';
function mockJwks() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ keys: [jwk] }),
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ keys: [jwk] }),
}),
);
}
function makeToken(iss: string, overrides: object = {}) {
return jwtLib.sign(
{ sub: 'user-sub', email: 'user@example.com', ...overrides },
privateKey,
{ algorithm: 'RS256', audience: CLIENT_ID, issuer: iss, expiresIn: '1h' }
);
return jwtLib.sign({ sub: 'user-sub', email: 'user@example.com', ...overrides }, privateKey, {
algorithm: 'RS256',
audience: CLIENT_ID,
issuer: iss,
expiresIn: '1h',
});
}
const doc = { jwks_uri: JWKS_URI } as any;
afterEach(() => { vi.unstubAllGlobals(); });
afterEach(() => {
vi.unstubAllGlobals();
});
it('OIDC-SVC-033: accepts token whose iss matches expectedIssuer exactly', async () => {
mockJwks();
@@ -570,11 +579,11 @@ describe('verifyIdToken', () => {
it('OIDC-SVC-036: rejects token with wrong audience', async () => {
mockJwks();
const token = makeToken(ISSUER, {});
const wrongAudToken = jwtLib.sign(
{ sub: 'user-sub', iss: ISSUER },
privateKey,
{ algorithm: 'RS256', audience: 'wrong-client', expiresIn: '1h' }
);
const wrongAudToken = jwtLib.sign({ sub: 'user-sub', iss: ISSUER }, privateKey, {
algorithm: 'RS256',
audience: 'wrong-client',
expiresIn: '1h',
});
const result = await verifyIdToken(wrongAudToken, doc, CLIENT_ID, ISSUER);
expect(result.ok).toBe(false);
});
@@ -2,6 +2,19 @@
* Unit tests for packingService.ts uncovered functions.
* Covers PACK-SVC-001 to PACK-SVC-012.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
saveAsTemplate,
applyTemplate,
setBagMembers,
createBag,
deleteBag,
bulkImport,
} from '../../../src/services/packingService';
import { createUser, createTrip } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB mock setup (vi.hoisted so it is available before vi.mock calls) ────────
@@ -30,19 +43,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
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 {
saveAsTemplate,
applyTemplate,
setBagMembers,
createBag,
deleteBag,
bulkImport,
} from '../../../src/services/packingService';
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeAll(() => {
@@ -65,9 +65,15 @@ describe('saveAsTemplate', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shirt', 'Clothes', 0);
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Shorts', 'Clothes', 1);
testDb.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)').run(trip.id, 'Toothbrush', 'Toiletries', 2);
testDb
.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)')
.run(trip.id, 'Shirt', 'Clothes', 0);
testDb
.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)')
.run(trip.id, 'Shorts', 'Clothes', 1);
testDb
.prepare('INSERT INTO packing_items (trip_id, name, category, checked, sort_order) VALUES (?, ?, ?, 0, ?)')
.run(trip.id, 'Toothbrush', 'Toiletries', 2);
const result = saveAsTemplate(trip.id, user.id, 'My Template');
@@ -100,14 +106,22 @@ describe('applyTemplate', () => {
const trip = createTrip(testDb, user.id);
// Insert a template with one category and two items directly
const templateResult = testDb.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run('Camping', user.id);
const templateResult = testDb
.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)')
.run('Camping', user.id);
const templateId = templateResult.lastInsertRowid as number;
const catResult = testDb.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)').run(templateId, 'Gear', 0);
const catResult = testDb
.prepare('INSERT INTO packing_template_categories (template_id, name, sort_order) VALUES (?, ?, ?)')
.run(templateId, 'Gear', 0);
const catId = catResult.lastInsertRowid as number;
testDb.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, 'Tent', 0);
testDb.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)').run(catId, 'Sleeping Bag', 1);
testDb
.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)')
.run(catId, 'Tent', 0);
testDb
.prepare('INSERT INTO packing_template_items (category_id, name, sort_order) VALUES (?, ?, ?)')
.run(catId, 'Sleeping Bag', 1);
const result = applyTemplate(trip.id, templateId);
@@ -125,7 +139,9 @@ describe('applyTemplate', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const templateResult = testDb.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)').run('Empty Template', user.id);
const templateResult = testDb
.prepare('INSERT INTO packing_templates (name, created_by) VALUES (?, ?)')
.run('Empty Template', user.id);
const templateId = templateResult.lastInsertRowid as number;
const result = applyTemplate(trip.id, templateId);
@@ -225,7 +241,9 @@ describe('bulkImport with bag field', () => {
expect(result).toHaveLength(1);
expect(result[0]).toBeDefined();
const bags = testDb.prepare('SELECT * FROM packing_bags WHERE trip_id = ? AND name = ?').all(trip.id, 'Carry-On') as any[];
const bags = testDb
.prepare('SELECT * FROM packing_bags WHERE trip_id = ? AND name = ?')
.all(trip.id, 'Carry-On') as any[];
expect(bags).toHaveLength(1);
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
@@ -244,7 +262,9 @@ describe('bulkImport with bag field', () => {
expect(result).toHaveLength(2);
const bags = testDb.prepare('SELECT * FROM packing_bags WHERE trip_id = ? AND name = ?').all(trip.id, 'Carry-On') as any[];
const bags = testDb
.prepare('SELECT * FROM packing_bags WHERE trip_id = ? AND name = ?')
.all(trip.id, 'Carry-On') as any[];
expect(bags).toHaveLength(1);
const items = testDb.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(trip.id) as any[];
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest';
import { validatePassword } from '../../../src/services/passwordPolicy';
import { describe, it, expect } from 'vitest';
describe('validatePassword', () => {
// AUTH-006 — Registration with weak password
describe('length requirement', () => {
@@ -1,3 +1,11 @@
import {
checkPermission,
getPermissionLevel,
savePermissions,
invalidatePermissionsCache,
PERMISSION_ACTIONS,
} from '../../../src/services/permissions';
import { describe, it, expect, vi } from 'vitest';
// Mutable rows array so individual tests can inject DB rows
@@ -15,8 +23,6 @@ vi.mock('../../../src/db/database', () => ({
},
}));
import { checkPermission, getPermissionLevel, savePermissions, invalidatePermissionsCache, PERMISSION_ACTIONS } from '../../../src/services/permissions';
describe('permissions', () => {
describe('checkPermission — admin bypass', () => {
it('admin always passes regardless of permission level', () => {
+89 -38
View File
@@ -3,6 +3,24 @@
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
* Skips importGpx / importGoogleList / searchPlaceImage (require external I/O).
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
listPlaces,
createPlace as svcCreatePlace,
getPlace,
updatePlace,
deletePlace,
importGpx,
importKmlPlaces,
importGoogleList,
searchPlaceImage,
} from '../../../src/services/placeService';
import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import fs from 'fs';
import path from 'path';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -18,16 +36,32 @@ const { testDb, dbMock } = vi.hoisted(() => {
closeDb: () => {},
reinitialize: () => {},
getPlaceWithTags: (placeId: any) => {
const place: any = db.prepare(`
const place: any = db
.prepare(
`
SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?
`).get(placeId);
`,
)
.get(placeId);
if (!place) return null;
const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId);
return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags };
const tags = db
.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`)
.all(placeId);
return {
...place,
category: place.category_id
? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon }
: null,
tags,
};
},
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`SELECT t.id, t.user_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),
db
.prepare(
`SELECT t.id, t.user_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),
};
@@ -41,14 +75,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories';
import path from 'path';
import fs from 'fs';
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx');
const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml');
@@ -358,7 +384,7 @@ describe('importGoogleList', () => {
it('PLACE-SVC-026 — returns error when list ID cannot be extracted from URL', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = await importGoogleList(String(trip.id), 'https://example.com/no-id-here') as any;
const result = (await importGoogleList(String(trip.id), 'https://example.com/no-id-here')) as any;
expect(result.error).toMatch(/Could not extract list ID/);
expect(result.status).toBe(400);
});
@@ -368,7 +394,7 @@ describe('importGoogleList', () => {
const trip = createTrip(testDb, user.id);
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, text: async () => '', status: 502 }));
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
const result = (await importGoogleList(String(trip.id), url)) as any;
expect(result.error).toMatch(/Failed to fetch list/);
expect(result.status).toBe(502);
});
@@ -378,18 +404,31 @@ describe('importGoogleList', () => {
const trip = createTrip(testDb, user.id);
const listPayload = [
[null, null, null, null, 'My Test List', null, null, null, [
[null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null],
[null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'],
]],
[
null,
null,
null,
null,
'My Test List',
null,
null,
null,
[
[null, [null, null, null, null, null, [null, null, 48.8566, 2.3522]], 'Paris', null],
[null, [null, null, null, null, null, [null, null, 51.5074, -0.1278]], 'London', 'Great city'],
],
],
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}),
);
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
const result = (await importGoogleList(String(trip.id), url)) as any;
expect(result.listName).toBe('My Test List');
expect(result.places).toHaveLength(2);
expect(result.places[0].name).toBe('Paris');
@@ -401,13 +440,16 @@ describe('importGoogleList', () => {
const trip = createTrip(testDb, user.id);
const listPayload = [[null, null, null, null, 'Empty List', null, null, null, []]];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
text: async () => 'prefix\n' + JSON.stringify(listPayload),
}),
);
const url = 'https://www.google.com/maps/placelists/list/ABC123DEF456';
const result = await importGoogleList(String(trip.id), url) as any;
const result = (await importGoogleList(String(trip.id), url)) as any;
expect(result.error).toBeDefined();
expect(result.status).toBe(400);
});
@@ -423,7 +465,7 @@ describe('searchPlaceImage', () => {
it('PLACE-SVC-030 — returns 404 when place does not exist', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const result = await searchPlaceImage(String(trip.id), '99999', user.id) as any;
const result = (await searchPlaceImage(String(trip.id), '99999', user.id)) as any;
expect(result.error).toBeDefined();
expect(result.status).toBe(404);
});
@@ -432,7 +474,7 @@ describe('searchPlaceImage', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }) as any;
const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any;
const result = (await searchPlaceImage(String(trip.id), String(place.id), user.id)) as any;
expect(result.error).toMatch(/No Unsplash API key/);
expect(result.status).toBe(400);
});
@@ -444,15 +486,24 @@ describe('searchPlaceImage', () => {
testDb.prepare('UPDATE users SET unsplash_api_key = ? WHERE id = ?').run('test-unsplash-key', user.id);
const mockPhotos = [
{ id: 'photo1', urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' }, description: 'Tower', user: { name: 'Photographer' }, links: { html: 'https://unsplash.com/1' } },
{
id: 'photo1',
urls: { regular: 'https://img.example.com/1', thumb: 'https://img.example.com/t1' },
description: 'Tower',
user: { name: 'Photographer' },
links: { html: 'https://unsplash.com/1' },
},
];
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ results: mockPhotos }),
status: 200,
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => ({ results: mockPhotos }),
status: 200,
}),
);
const result = await searchPlaceImage(String(trip.id), String(place.id), user.id) as any;
const result = (await searchPlaceImage(String(trip.id), String(place.id), user.id)) as any;
expect(result.photos).toHaveLength(1);
expect(result.photos[0].id).toBe('photo1');
expect(result.photos[0].url).toBe('https://img.example.com/1');
@@ -1,12 +1,12 @@
import { formatAssignmentWithPlace } from '../../../src/services/queryHelpers';
import type { AssignmentRow, Tag, Participant } from '../../../src/types';
import { describe, it, expect, vi } from 'vitest';
vi.mock('../../../src/db/database', () => ({
db: { prepare: () => ({ all: () => [], get: vi.fn() }) },
}));
import { formatAssignmentWithPlace } from '../../../src/services/queryHelpers';
import type { AssignmentRow, Tag, Participant } from '../../../src/types';
function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
return {
id: 1,
@@ -39,13 +39,9 @@ function makeRow(overrides: Partial<AssignmentRow> = {}): AssignmentRow {
} as AssignmentRow;
}
const sampleTags: Partial<Tag>[] = [
{ id: 1, name: 'Must-see', color: '#ef4444' },
];
const sampleTags: Partial<Tag>[] = [{ id: 1, name: 'Must-see', color: '#ef4444' }];
const sampleParticipants: Participant[] = [
{ user_id: 42, username: 'alice', avatar: null },
];
const sampleParticipants: Participant[] = [{ user_id: 42, username: 'alice', avatar: null }];
describe('formatAssignmentWithPlace', () => {
it('nests place fields correctly from flat row', () => {
@@ -3,6 +3,12 @@
* Uses a real in-memory SQLite DB; apiKeyCrypto is mocked to a passthrough
* so we don't need real encryption for most tests.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { getUserSettings, upsertSetting, bulkUpsertSettings } from '../../../src/services/settingsService';
import { createUser } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB + apiKeyCrypto mock ────────────────────────────────────────────────────
@@ -36,12 +42,6 @@ vi.mock('../../../src/services/apiKeyCrypto', () => ({
maybe_encrypt_api_key: (v: string) => v,
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { getUserSettings, upsertSetting, bulkUpsertSettings } from '../../../src/services/settingsService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -90,7 +90,9 @@ describe('getUserSettings', () => {
it('SET-SVC-005 — webhook_url with a value is masked as ••••••••', () => {
const { user } = createUser(testDb);
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://secret.example.com')").run(user.id);
testDb
.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'webhook_url', 'https://secret.example.com')")
.run(user.id);
const s = getUserSettings(user.id);
expect(s.webhook_url).toBe('••••••••');
});
@@ -141,7 +143,9 @@ describe('upsertSetting', () => {
it('SET-SVC-011 — serializes boolean values as strings', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'notifications', true);
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'notifications'").get(user.id) as any;
const raw = testDb
.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'notifications'")
.get(user.id) as any;
expect(raw.value).toBe('true');
});
@@ -149,7 +153,9 @@ describe('upsertSetting', () => {
const { user } = createUser(testDb);
upsertSetting(user.id, 'webhook_url', 'https://hook.example.com');
// With passthrough mock, value is stored as-is
const raw = testDb.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'").get(user.id) as any;
const raw = testDb
.prepare("SELECT value FROM settings WHERE user_id = ? AND key = 'webhook_url'")
.get(user.id) as any;
expect(raw.value).toBe('https://hook.example.com');
// But getUserSettings masks it
const s = getUserSettings(user.id);
@@ -215,7 +221,11 @@ describe('bulkUpsertSettings', () => {
vi.spyOn(testDb, 'prepare').mockImplementationOnce((sql: string) => {
const stmt = origPrepare(sql);
intercepted = true;
return { run: () => { throw new Error('forced DB error'); } } as any;
return {
run: () => {
throw new Error('forced DB error');
},
} as any;
});
expect(() => bulkUpsertSettings(user.id, { k: 'v' })).toThrow('forced DB error');
expect(intercepted).toBe(true);
@@ -2,6 +2,12 @@
* Unit tests for tagService TAG-SVC-001 through TAG-SVC-015.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../../src/services/tagService';
import { createUser } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -30,12 +36,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../../src/services/tagService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
+24 -18
View File
@@ -2,6 +2,21 @@
* Unit tests for todoService TODO-SVC-001 through TODO-SVC-020.
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
verifyTripAccess,
listItems,
createItem,
updateItem,
deleteItem,
getCategoryAssignees,
updateCategoryAssignees,
reorderItems,
} from '../../../src/services/todoService';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -18,11 +33,15 @@ const { testDb, dbMock } = vi.hoisted(() => {
reinitialize: () => {},
getPlaceWithTags: () => null,
canAccessTrip: (tripId: any, userId: number) =>
db.prepare(`
db
.prepare(
`
SELECT t.id, t.user_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),
`,
)
.get(userId, tripId, userId),
isOwner: (tripId: any, userId: number) =>
!!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId),
};
@@ -36,21 +55,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, addTripMember } from '../../helpers/factories';
import {
verifyTripAccess,
listItems,
createItem,
updateItem,
deleteItem,
getCategoryAssignees,
updateCategoryAssignees,
reorderItems,
} from '../../../src/services/todoService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -214,7 +218,9 @@ describe('reorderItems', () => {
reorderItems(trip.id, [c.id, a.id, b.id]);
const rows = testDb.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order').all(trip.id) as any[];
const rows = testDb
.prepare('SELECT id, sort_order FROM todo_items WHERE trip_id = ? ORDER BY sort_order')
.all(trip.id) as any[];
expect(rows[0].id).toBe(c.id);
expect(rows[1].id).toBe(a.id);
expect(rows[2].id).toBe(b.id);
@@ -3,10 +3,11 @@
* leading/trailing whitespace in stored usernames and emails.
* Tests TRIM-MIG-001 through TRIM-MIG-010.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import Database from 'better-sqlite3';
import { trimUserWhitespace } from '../../../src/db/migrations';
import Database from 'better-sqlite3';
import { describe, it, expect, vi, beforeEach } from 'vitest';
function makeDb() {
const db = new Database(':memory:');
db.exec('PRAGMA foreign_keys = ON');
+64 -40
View File
@@ -2,6 +2,20 @@
* Unit tests for tripService exportICS function (TRIP-SVC-001 through TRIP-SVC-009).
* Uses a real in-memory SQLite DB so SQL logic is exercised faithfully.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { exportICS, generateDays } from '../../../src/services/tripService';
import {
createUser,
createTrip,
createReservation,
createPlace,
createDay,
createDayAssignment,
createDayNote,
} from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup ──────────────────────────────────────────────────────────────────
@@ -30,12 +44,6 @@ vi.mock('../../../src/config', () => ({
updateJwtSecret: () => {},
}));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createReservation, createPlace, createDay, createDayAssignment, createDayNote } from '../../helpers/factories';
import { exportICS, generateDays } from '../../../src/services/tripService';
beforeAll(() => {
createTables(testDb);
runMigrations(testDb);
@@ -53,12 +61,18 @@ afterAll(() => {
function getDays(tripId: number) {
return testDb.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(tripId) as {
id: number; trip_id: number; day_number: number; date: string | null;
id: number;
trip_id: number;
day_number: number;
date: string | null;
}[];
}
function getAssignments(dayId: number) {
return testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(dayId) as { id: number; day_id: number }[];
return testDb.prepare('SELECT * FROM day_assignments WHERE day_id = ?').all(dayId) as {
id: number;
day_id: number;
}[];
}
function getNotes(dayId: number) {
@@ -83,8 +97,12 @@ describe('generateDays', () => {
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-06-10', '2025-06-11', '2025-06-12', '2025-06-13', '2025-06-14',
expect(daysAfter.map((d) => d.date)).toEqual([
'2025-06-10',
'2025-06-11',
'2025-06-12',
'2025-06-13',
'2025-06-14',
]);
// day_number 1 (formerly June 1) now has date June 10 — assignment still attached
@@ -111,7 +129,7 @@ describe('generateDays', () => {
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(3);
expect(daysAfter.map(d => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']);
expect(daysAfter.map((d) => d.date)).toEqual(['2025-07-01', '2025-07-02', '2025-07-03']);
});
it('TRIP-SVC-016: shrinking range deletes empty overflow days (issue #909)', () => {
@@ -124,8 +142,12 @@ describe('generateDays', () => {
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-07-01', '2025-07-02', '2025-07-03', '2025-07-04', '2025-07-05',
expect(daysAfter.map((d) => d.date)).toEqual([
'2025-07-01',
'2025-07-02',
'2025-07-03',
'2025-07-04',
'2025-07-05',
]);
});
@@ -143,8 +165,12 @@ describe('generateDays', () => {
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-08-01', '2025-08-02', '2025-08-03', '2025-08-04', '2025-08-05',
expect(daysAfter.map((d) => d.date)).toEqual([
'2025-08-01',
'2025-08-02',
'2025-08-03',
'2025-08-04',
'2025-08-05',
]);
// Existing day 1 retains its assignment
@@ -170,10 +196,10 @@ describe('generateDays', () => {
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(4);
expect(daysAfter.every(d => d.date === null)).toBe(true);
expect(daysAfter.every((d) => d.date === null)).toBe(true);
// The assignment on the former day 2 still exists
const formerDay2 = daysAfter.find(d => d.id === daysBefore[1].id);
const formerDay2 = daysAfter.find((d) => d.id === daysBefore[1].id);
expect(formerDay2).toBeDefined();
expect(getAssignments(formerDay2!.id)).toHaveLength(1);
expect(getAssignments(formerDay2!.id)[0].id).toBe(assignment.id);
@@ -193,8 +219,12 @@ describe('generateDays', () => {
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
expect(daysAfter.map(d => d.date)).toEqual([
'2025-10-03', '2025-10-04', '2025-10-05', '2025-10-06', '2025-10-07',
expect(daysAfter.map((d) => d.date)).toEqual([
'2025-10-03',
'2025-10-04',
'2025-10-05',
'2025-10-06',
'2025-10-07',
]);
// All 5 assignments survive
@@ -229,8 +259,8 @@ describe('generateDays', () => {
const daysAfter = getDays(trip.id);
expect(daysAfter).toHaveLength(5);
const dated = daysAfter.filter(d => d.date !== null);
const dateless = daysAfter.filter(d => d.date === null);
const dated = daysAfter.filter((d) => d.date !== null);
const dateless = daysAfter.filter((d) => d.date === null);
expect(dated).toHaveLength(4);
expect(dateless).toHaveLength(1);
@@ -239,7 +269,7 @@ describe('generateDays', () => {
expect(getAssignments(dateless[0].id)[0].id).toBe(assignment.id);
// All day_numbers are unique 1..5
const nums = daysAfter.map(d => d.day_number).sort((a, b) => a - b);
const nums = daysAfter.map((d) => d.day_number).sort((a, b) => a - b);
expect(nums).toEqual([1, 2, 3, 4, 5]);
});
});
@@ -280,9 +310,7 @@ describe('exportICS', () => {
title: 'Morning Flight',
type: 'flight',
});
testDb
.prepare('UPDATE reservations SET reservation_time=? WHERE id=?')
.run('2025-06-02T09:00', reservation.id);
testDb.prepare('UPDATE reservations SET reservation_time=? WHERE id=?').run('2025-06-02T09:00', reservation.id);
const { ics } = exportICS(trip.id);
@@ -297,9 +325,7 @@ describe('exportICS', () => {
title: 'Hotel Check-in',
type: 'hotel',
});
testDb
.prepare('UPDATE reservations SET reservation_time=? WHERE id=?')
.run('2025-06-02', reservation.id);
testDb.prepare('UPDATE reservations SET reservation_time=? WHERE id=?').run('2025-06-02', reservation.id);
const { ics } = exportICS(trip.id);
@@ -313,18 +339,16 @@ describe('exportICS', () => {
title: 'CDG to JFK',
type: 'flight',
});
testDb
.prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?')
.run(
'2025-06-02T09:00',
JSON.stringify({
airline: 'Air Test',
flight_number: 'AT100',
departure_airport: 'CDG',
arrival_airport: 'JFK',
}),
reservation.id
);
testDb.prepare('UPDATE reservations SET reservation_time=?, metadata=? WHERE id=?').run(
'2025-06-02T09:00',
JSON.stringify({
airline: 'Air Test',
flight_number: 'AT100',
departure_airport: 'CDG',
arrival_airport: 'JFK',
}),
reservation.id,
);
const { ics } = exportICS(trip.id);
+74 -71
View File
@@ -1,3 +1,31 @@
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import {
getOwnPlan,
getActivePlan,
getPlanUsers,
migrateHolidayCalendars,
updatePlan,
addHolidayCalendar,
updateHolidayCalendar,
deleteHolidayCalendar,
setUserColor,
acceptInvite,
declineInvite,
cancelInvite,
getAvailableUsers,
listYears,
addYear,
deleteYear,
getEntries,
toggleEntry,
toggleCompanyHoliday,
getStats,
applyHolidayCalendars,
} from '../../../src/services/vacayService';
import { createUser } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
// ── DB setup (real in-memory SQLite) ─────────────────────────────────────────
@@ -26,35 +54,6 @@ vi.mock('../../../src/config', () => ({
// Mock websocket so notifyPlanUsers doesn't throw
vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createUser } from '../../helpers/factories';
import {
getOwnPlan,
getActivePlan,
getPlanUsers,
migrateHolidayCalendars,
updatePlan,
addHolidayCalendar,
updateHolidayCalendar,
deleteHolidayCalendar,
setUserColor,
acceptInvite,
declineInvite,
cancelInvite,
getAvailableUsers,
listYears,
addYear,
deleteYear,
getEntries,
toggleEntry,
toggleCompanyHoliday,
getStats,
applyHolidayCalendars,
} from '../../../src/services/vacayService';
// ── Lifecycle ─────────────────────────────────────────────────────────────────
beforeAll(() => {
@@ -66,10 +65,13 @@ beforeEach(() => {
resetTestDb(testDb);
// Stub fetch with empty holiday list by default so updatePlan / applyHolidayCalendars
// never makes real network calls.
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => [],
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => [],
}),
);
});
afterAll(() => {
@@ -81,9 +83,9 @@ afterAll(() => {
/** Insert a vacay_plan_members row directly (no service factory for it). */
function insertMember(planId: number, userId: number, status: 'pending' | 'accepted'): void {
testDb.prepare(
"INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)"
).run(planId, userId, status);
testDb
.prepare('INSERT INTO vacay_plan_members (plan_id, user_id, status) VALUES (?, ?, ?)')
.run(planId, userId, status);
}
/** Fast helper: create a user and immediately materialise their own plan. */
@@ -118,9 +120,7 @@ describe('getOwnPlan', () => {
const plan = getOwnPlan(user.id);
const yr = new Date().getFullYear();
const row = testDb
.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?')
.get(plan.id, yr);
const row = testDb.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?').get(plan.id, yr);
expect(row).toBeDefined();
});
@@ -194,8 +194,8 @@ describe('getPlanUsers', () => {
const users = getPlanUsers(plan.id);
expect(users).toHaveLength(2);
expect(users.map(u => u.id)).toContain(owner.id);
expect(users.map(u => u.id)).toContain(member.id);
expect(users.map((u) => u.id)).toContain(owner.id);
expect(users.map((u) => u.id)).toContain(member.id);
});
it('VACAY-SVC-010: pending membership members are NOT included in plan users', () => {
@@ -204,7 +204,7 @@ describe('getPlanUsers', () => {
insertMember(plan.id, pendingUser.id, 'pending');
const users = getPlanUsers(plan.id);
expect(users.map(u => u.id)).not.toContain(pendingUser.id);
expect(users.map((u) => u.id)).not.toContain(pendingUser.id);
});
it('VACAY-SVC-011: returns empty array for a non-existent plan id', () => {
@@ -222,9 +222,7 @@ describe('migrateHolidayCalendars', () => {
await migrateHolidayCalendars(plan.id, planRow);
const rows = testDb
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
.all(plan.id);
const rows = testDb.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?').all(plan.id);
expect(rows).toHaveLength(0);
});
@@ -234,9 +232,9 @@ describe('migrateHolidayCalendars', () => {
await migrateHolidayCalendars(plan.id, planRow);
const rows = testDb
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
.all(plan.id) as { region: string }[];
const rows = testDb.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?').all(plan.id) as {
region: string;
}[];
expect(rows).toHaveLength(1);
expect(rows[0].region).toBe('DE');
});
@@ -249,9 +247,7 @@ describe('migrateHolidayCalendars', () => {
// Call a second time — should NOT insert another row
await migrateHolidayCalendars(plan.id, planRow);
const rows = testDb
.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?')
.all(plan.id);
const rows = testDb.prepare('SELECT * FROM vacay_holiday_calendars WHERE plan_id = ?').all(plan.id);
expect(rows).toHaveLength(1);
});
});
@@ -264,9 +260,9 @@ describe('updatePlan', () => {
await updatePlan(plan.id, { block_weekends: true }, undefined);
const updated = testDb
.prepare('SELECT block_weekends FROM vacay_plans WHERE id = ?')
.get(plan.id) as { block_weekends: number };
const updated = testDb.prepare('SELECT block_weekends FROM vacay_plans WHERE id = ?').get(plan.id) as {
block_weekends: number;
};
expect(updated.block_weekends).toBe(1);
});
@@ -275,9 +271,9 @@ describe('updatePlan', () => {
await updatePlan(plan.id, { holidays_enabled: true }, undefined);
const updated = testDb
.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?')
.get(plan.id) as { holidays_enabled: number };
const updated = testDb.prepare('SELECT holidays_enabled FROM vacay_plans WHERE id = ?').get(plan.id) as {
holidays_enabled: number;
};
expect(updated.holidays_enabled).toBe(1);
});
@@ -443,14 +439,20 @@ describe('addYear', () => {
// Enable carry-over and seed some entries for the current year
testDb.prepare('UPDATE vacay_plans SET carry_over_enabled = 1 WHERE id = ?').run(plan.id);
// Ensure current year row exists with 10 vacation days
testDb.prepare(`
testDb
.prepare(
`
INSERT OR REPLACE INTO vacay_user_years (user_id, plan_id, year, vacation_days, carried_over)
VALUES (?, ?, ?, 10, 0)
`).run(user.id, plan.id, currentYear);
`,
)
.run(user.id, plan.id, currentYear);
// Add 3 entries (used days) in the current year
for (let day = 1; day <= 3; day++) {
const dateStr = `${currentYear}-06-0${day}`;
testDb.prepare('INSERT OR IGNORE INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)').run(plan.id, user.id, dateStr, '');
testDb
.prepare('INSERT OR IGNORE INTO vacay_entries (plan_id, user_id, date, note) VALUES (?, ?, ?, ?)')
.run(plan.id, user.id, dateStr, '');
}
addYear(plan.id, nextYear, undefined);
@@ -476,13 +478,11 @@ describe('deleteYear', () => {
deleteYear(plan.id, targetYear, undefined);
const yearRow = testDb
.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?')
.get(plan.id, targetYear);
const yearRow = testDb.prepare('SELECT * FROM vacay_years WHERE plan_id = ? AND year = ?').get(plan.id, targetYear);
expect(yearRow).toBeUndefined();
const entries = testDb
.prepare("SELECT * FROM vacay_entries WHERE plan_id = ? AND date LIKE ?")
.prepare('SELECT * FROM vacay_entries WHERE plan_id = ? AND date LIKE ?')
.all(plan.id, `${targetYear}-%`);
expect(entries).toHaveLength(0);
});
@@ -653,9 +653,9 @@ describe('getAvailableUsers', () => {
const available = getAvailableUsers(owner.id, plan.id) as { id: number }[];
expect(available.map(u => u.id)).toContain(unrelated.id);
expect(available.map((u) => u.id)).toContain(unrelated.id);
// Owner themselves should NOT appear (excluded by u.id != ?)
expect(available.map(u => u.id)).not.toContain(owner.id);
expect(available.map((u) => u.id)).not.toContain(owner.id);
});
it('VACAY-SVC-043: excludes users who already have an accepted membership in any plan', () => {
@@ -666,7 +666,7 @@ describe('getAvailableUsers', () => {
const available = getAvailableUsers(owner.id, plan.id) as { id: number }[];
expect(available.map(u => u.id)).not.toContain(alreadyFused.id);
expect(available.map((u) => u.id)).not.toContain(alreadyFused.id);
});
});
@@ -730,10 +730,13 @@ describe('applyHolidayCalendars', () => {
.run(plan.id, user.id, holidayDate, '');
// Override fetch to return one global holiday matching that entry
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ date: holidayDate, global: true }],
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok: true,
json: async () => [{ date: holidayDate, global: true }],
}),
);
await applyHolidayCalendars(plan.id);
@@ -2,6 +2,12 @@
* Unit tests for checkAndNotifyVersion() in adminService.
* Covers VNOTIF-001 to VNOTIF-007.
*/
import { runMigrations } from '../../../src/db/migrations';
import { createTables } from '../../../src/db/schema';
import { checkAndNotifyVersion, __clearVersionCacheForTests } from '../../../src/services/adminService';
import { createAdmin } from '../../helpers/factories';
import { resetTestDb } from '../../helpers/test-db';
import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest';
const { testDb, dbMock } = vi.hoisted(() => {
@@ -30,18 +36,15 @@ vi.mock('../../../src/websocket', () => ({ broadcastToUser: vi.fn() }));
// Mock MCP to avoid session side-effects
vi.mock('../../../src/mcp', () => ({ revokeUserSessions: vi.fn() }));
import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db';
import { createAdmin } from '../../helpers/factories';
import { checkAndNotifyVersion, __clearVersionCacheForTests } from '../../../src/services/adminService';
// Helper: mock the GitHub releases/latest endpoint
function mockGitHubLatest(tagName: string, ok = true): void {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
ok,
json: async () => ({ tag_name: tagName, html_url: `https://github.com/mauriceboe/TREK/releases/tag/${tagName}` }),
}));
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue({
ok,
json: async () => ({ tag_name: tagName, html_url: `https://github.com/mauriceboe/TREK/releases/tag/${tagName}` }),
}),
);
}
function mockGitHubFetchFailure(): void {
@@ -49,7 +52,11 @@ function mockGitHubFetchFailure(): void {
}
function getLastNotifiedVersion(): string | undefined {
return (testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as { value: string } | undefined)?.value;
return (
testDb.prepare('SELECT value FROM app_settings WHERE key = ?').get('last_notified_version') as
| { value: string }
| undefined
)?.value;
}
function getNotificationCount(): number {
@@ -96,9 +103,13 @@ describe('checkAndNotifyVersion', () => {
await checkAndNotifyVersion();
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{ recipient_id: number; type: string; scope: string }>;
const notifications = testDb.prepare('SELECT * FROM notifications ORDER BY id').all() as Array<{
recipient_id: number;
type: string;
scope: string;
}>;
expect(notifications.length).toBe(2);
const recipientIds = notifications.map(n => n.recipient_id);
const recipientIds = notifications.map((n) => n.recipient_id);
expect(recipientIds).toContain(admin1.id);
expect(recipientIds).toContain(admin2.id);
expect(notifications[0].type).toBe('navigate');
@@ -131,7 +142,9 @@ describe('checkAndNotifyVersion', () => {
it('VNOTIF-005 — creates new notification when last_notified_version is an older version', async () => {
createAdmin(testDb);
// Simulate having been notified about an older version
testDb.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)').run('last_notified_version', '98.0.0');
testDb
.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
.run('last_notified_version', '98.0.0');
mockGitHubLatest('v99.3.0');
await checkAndNotifyVersion();
@@ -1,3 +1,12 @@
import {
estimateCondition,
cacheKey,
getWeather,
getDetailedWeather,
ApiError,
type WeatherResult,
} from '../../../src/services/weatherService';
import { describe, it, expect, vi, beforeEach, afterAll } from 'vitest';
// Prevent the module-level setInterval from running during tests
@@ -8,15 +17,6 @@ vi.stubGlobal('fetch', vi.fn());
afterAll(() => vi.unstubAllGlobals());
import {
estimateCondition,
cacheKey,
getWeather,
getDetailedWeather,
ApiError,
type WeatherResult,
} from '../../../src/services/weatherService';
// ── estimateCondition ────────────────────────────────────────────────────────
describe('estimateCondition', () => {
@@ -271,8 +271,8 @@ describe('getWeather', () => {
};
vi.mocked(fetch)
.mockResolvedValueOnce(mockResponse(forecastBody))
.mockResolvedValueOnce(mockResponse(archiveBody));
.mockResolvedValueOnce(mockResponse(forecastBody))
.mockResolvedValueOnce(mockResponse(archiveBody));
const result = await getWeather('13.00', '23.00', date, 'en');
@@ -307,7 +307,9 @@ describe('getWeather', () => {
it('returns no_forecast error when archive has no data for the date', async () => {
const date = dateOffset(-5);
vi.mocked(fetch).mockResolvedValueOnce(mockResponse({ daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [] } }));
vi.mocked(fetch).mockResolvedValueOnce(
mockResponse({ daily: { time: [], temperature_2m_max: [], temperature_2m_min: [], weathercode: [] } }),
);
const result = await getWeather('14.01', '24.01', date, 'en');
+2 -1
View File
@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest';
// Smoke test: proves the server toolchain (tsx / vitest) resolves @trek/shared.
import { idParamSchema, paginationQuerySchema } from '@trek/shared';
import { describe, it, expect } from 'vitest';
describe('@trek/shared resolves in the server toolchain', () => {
it('imports and uses a shared schema', () => {
expect(idParamSchema.parse('7')).toBe(7);
@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest';
import { evaluate } from '../../../src/systemNotices/conditions.js';
import type { SystemNotice } from '../../../src/systemNotices/types.js';
import { describe, it, expect } from 'vitest';
const baseNotice: SystemNotice = {
id: 'test',
display: 'modal',
@@ -50,7 +51,10 @@ describe('existingUserBeforeVersion', () => {
describe('dateWindow', () => {
it('passes when now is inside window', () => {
const notice = { ...baseNotice, conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }] };
const notice = {
...baseNotice,
conditions: [{ kind: 'dateWindow' as const, startsAt: '2026-05-01T00:00:00Z', endsAt: '2026-07-01T00:00:00Z' }],
};
expect(evaluate(notice, baseCtx)).toBe(true);
});
it('fails when now is before start', () => {
@@ -76,10 +80,10 @@ describe('role', () => {
describe('AND logic', () => {
it('requires all conditions to pass', () => {
const notice = { ...baseNotice, conditions: [
{ kind: 'firstLogin' as const },
{ kind: 'role' as const, roles: ['user'] },
]};
const notice = {
...baseNotice,
conditions: [{ kind: 'firstLogin' as const }, { kind: 'role' as const, roles: ['user'] }],
};
// login_count=1 passes firstLogin, role=user passes role → true
expect(evaluate(notice, { ...baseCtx, user: { ...baseCtx.user, login_count: 1 } })).toBe(true);
// login_count=2 fails firstLogin → false
@@ -1,8 +1,9 @@
import { describe, it, expect } from 'vitest';
import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
import fs from 'node:fs';
import path from 'node:path';
import semver from 'semver';
import { SYSTEM_NOTICES } from '../../../src/systemNotices/registry.js';
import { describe, it, expect } from 'vitest';
/** Collect all actionIds registered via registerNoticeAction() in client source files. */
function collectRegisteredActionIds(): Set<string> {
@@ -13,7 +14,10 @@ function collectRegisteredActionIds(): Set<string> {
const dir = queue.pop()!;
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) { queue.push(full); continue; }
if (entry.isDirectory()) {
queue.push(full);
continue;
}
if (!entry.name.endsWith('noticeActions.ts') && !entry.name.endsWith('noticeActions.js')) continue;
const src = fs.readFileSync(full, 'utf8');
for (const m of src.matchAll(/registerNoticeAction\(\s*['"]([^'"]+)['"]/g)) {
@@ -26,15 +30,15 @@ function collectRegisteredActionIds(): Set<string> {
describe('registry integrity', () => {
it('has no duplicate ids', () => {
const ids = SYSTEM_NOTICES.map(n => n.id);
const ids = SYSTEM_NOTICES.map((n) => n.id);
expect(new Set(ids).size).toBe(ids.length);
});
it('all action CTAs reference a registered actionId', () => {
const registeredActionIds = collectRegisteredActionIds();
const actionCtaIds = SYSTEM_NOTICES
.filter(n => n.cta?.kind === 'action')
.map(n => (n.cta as { actionId: string }).actionId);
const actionCtaIds = SYSTEM_NOTICES.filter((n) => n.cta?.kind === 'action').map(
(n) => (n.cta as { actionId: string }).actionId,
);
for (const id of actionCtaIds) {
expect(registeredActionIds, `actionId "${id}" not found in any client noticeActions.ts`).toContain(id);
@@ -58,7 +62,7 @@ describe('registry integrity', () => {
if (n.minVersion && n.maxVersion) {
expect(
semver.lte(n.minVersion, n.maxVersion),
`notice "${n.id}": minVersion ${n.minVersion} > maxVersion ${n.maxVersion}`
`notice "${n.id}": minVersion ${n.minVersion} > maxVersion ${n.maxVersion}`,
).toBe(true);
}
}
@@ -1,7 +1,8 @@
import { describe, it, expect } from 'vitest';
import { isNoticeVersionActive } from '../../../src/systemNotices/service.js';
import type { SystemNotice } from '../../../src/systemNotices/types.js';
import { describe, it, expect } from 'vitest';
const base: SystemNotice = {
id: 'test-notice',
display: 'modal',
+3 -4
View File
@@ -1,3 +1,6 @@
import { checkSsrf, SsrfBlockedError, safeFetch, createPinnedDispatcher } from '../../../src/utils/ssrfGuard';
import dns from 'dns/promises';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// Capture Agent constructor options so we can test the lookup callback
@@ -20,9 +23,6 @@ vi.mock('undici', () => ({
},
}));
import dns from 'dns/promises';
import { checkSsrf, SsrfBlockedError, safeFetch, createPinnedDispatcher } from '../../../src/utils/ssrfGuard';
const mockLookup = vi.mocked(dns.lookup);
function mockIp(ip: string) {
@@ -166,7 +166,6 @@ describe('checkSsrf', () => {
expect(result.error).toBe('Could not resolve hostname');
});
});
});
describe('SsrfBlockedError', () => {