mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-22 06:41:46 +00:00
chore: apply prettier on the entire project
This commit is contained in:
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user