mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-21 14:21:46 +00:00
chore: apply prettier on the entire project
This commit is contained in:
@@ -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,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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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() };
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) }));
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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:';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } : {}),
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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> & more';
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user