mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
test(server): seed the integration specs through the repositories
The buildApp() suites for auth, bookings, budget, journeys, assignments, passkeys, profiles, OAuth, the AirTrail tick and the plugin boot order, plus the storage restore suite, seeded settings, addons and fixture rows and read back what a request left behind through raw better-sqlite3 statements. They now write and read through the ORM factories and readers, against the app's own MikroORM (or the suite's test ORM where one already existed). The account deletion case keeps every one of its foreign key checks; a row that used to read back as undefined now reads back as null.
This commit is contained in:
@@ -53,6 +53,8 @@ import { AirtrailClient, type AirtrailFlightRaw } from '../../src/nest/integrati
|
||||
import { ReservationsService } from '../../src/nest/reservations/reservations.service';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
import { createTrip, createUser } from '../helpers/factories';
|
||||
import { deleteRows, insertRow } from '../helpers/factories/rows';
|
||||
import { Reservations } from '../../src/db/entities/Reservations.entity';
|
||||
|
||||
describe('airtrail-sync cron tick runs inside a request context', () => {
|
||||
let app: INestApplication;
|
||||
@@ -72,13 +74,10 @@ describe('airtrail-sync cron tick runs inside a request context', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
|
||||
const result = testDb
|
||||
.prepare(
|
||||
`INSERT INTO reservations (trip_id, title, type, status, external_source, external_id, external_owner_user_id, sync_enabled, external_hash)
|
||||
VALUES (?, 'AirTrail Flight', 'flight', 'confirmed', 'airtrail', '999', ?, 1, 'stale-hash')`,
|
||||
)
|
||||
.run(trip.id, user.id);
|
||||
const reservationId = result.lastInsertRowid as number;
|
||||
const reservationId = await insertRow(orm, Reservations, {
|
||||
trip: trip.id, title: 'AirTrail Flight', type: 'flight', status: 'confirmed', external_source: 'airtrail',
|
||||
external_id: '999', external_owner_user_id: user.id, sync_enabled: 1, external_hash: 'stale-hash',
|
||||
});
|
||||
|
||||
const flight: AirtrailFlightRaw = {
|
||||
id: 999,
|
||||
@@ -165,7 +164,7 @@ describe('airtrail-sync cron tick runs inside a request context', () => {
|
||||
flightsSpy.mockRestore();
|
||||
credsSpy.mockRestore();
|
||||
syncEnabledSpy.mockRestore();
|
||||
testDb.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId);
|
||||
await deleteRows(orm, Reservations, { id: reservationId });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,13 +27,20 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, addTripMember, createTag } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { countRows, findRows, updateRows } from '../helpers/factories/rows';
|
||||
import { tagPlace } from '../helpers/factories/places';
|
||||
import { DayAssignments } from '../../src/db/entities/DayAssignments.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: MikroORM;
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -66,7 +73,7 @@ describe('Create assignment', () => {
|
||||
.set('Cookie', authCookie(user.id)).send({ place_id: place.id });
|
||||
const id = created.body.assignment.id;
|
||||
const url = `/api/trips/${trip.id}/assignments/${id}/end-day`;
|
||||
testDb.prepare('UPDATE day_assignments SET assignment_time = ? WHERE id = ?').run('07:00', id);
|
||||
await updateRows(orm, DayAssignments, { id }, { assignment_time: '07:00' });
|
||||
const changed = await request(app).put(url).set('Cookie', authCookie(user.id)).send({ end_day: true }).expect(200);
|
||||
expect(changed.body.assignment).toMatchObject({ end_day: true, assignment_time: '07:00' });
|
||||
const listed = await request(app).get(`/api/trips/${trip.id}/days`).set('Cookie', authCookie(user.id)).expect(200);
|
||||
@@ -195,7 +202,7 @@ describe('List assignments', () => {
|
||||
it('ASSIGN-003 — the embedded place carries osm_id so the day-plan thumbnail can auto-fetch (#1136)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const { trip, day, place } = setupAssignmentFixtures(user.id);
|
||||
testDb.prepare('UPDATE places SET osm_id = ? WHERE id = ?').run('node:42', place.id);
|
||||
await updateRows(orm, Places, { id: place.id }, { osm_id: 'node:42' });
|
||||
|
||||
await request(app)
|
||||
.post(`/api/trips/${trip.id}/days/${day.id}/assignments`)
|
||||
@@ -295,9 +302,7 @@ describe('Reorder assignments', () => {
|
||||
expect(reorder.status).toBe(200);
|
||||
expect(reorder.body.success).toBe(true);
|
||||
|
||||
const rows = testDb
|
||||
.prepare('SELECT id, order_index FROM day_assignments WHERE day_id = ? ORDER BY order_index')
|
||||
.all(day.id) as Array<{ id: number; order_index: number }>;
|
||||
const rows = await findRows(orm, DayAssignments, { day: day.id }, { order_index: 'asc' });
|
||||
expect(rows[0].id).toBe(a2.body.assignment.id);
|
||||
expect(rows[1].id).toBe(a1.body.assignment.id);
|
||||
});
|
||||
@@ -368,7 +373,7 @@ describe('Assignment participants', () => {
|
||||
|
||||
// Attach a tag to the place
|
||||
const tag = createTag(testDb, user.id, { name: 'Must See' });
|
||||
testDb.prepare('INSERT INTO place_tags (place_id, tag_id) VALUES (?, ?)').run(place.id, tag.id);
|
||||
await tagPlace(orm, place.id, [tag.id]);
|
||||
|
||||
// Create the assignment via API
|
||||
const create = await request(app)
|
||||
@@ -436,6 +441,6 @@ describe('H1 — trip id parsed once at the gate (rule 21)', () => {
|
||||
.send({ place_id: place.id });
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Day not found' });
|
||||
expect(testDb.prepare('SELECT COUNT(*) AS n FROM day_assignments WHERE day_id = ?').get(day.id)).toEqual({ n: 0 });
|
||||
expect(await countRows(orm, DayAssignments, { day: day.id })).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,15 +33,62 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createUserWithMfa, createInviteToken, createTrip, createBudgetItem, createJourney, createJourneyEntry, addJourneyContributor, addTripPhoto, createCategory, createTag, createTodoItem, createMcpToken, createBucketListItem, createVisitedCountry, createCollabNote, addTripMember } from '../helpers/factories';
|
||||
import { authCookie, authHeader } from '../helpers/auth';
|
||||
import type { EntityClass, FilterQuery } from '@mikro-orm/core';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { dbNow } from '../../src/db/types';
|
||||
import { findRow, insertRow, insertRowIgnoringConflict, updateRows } from '../helpers/factories/rows';
|
||||
import { setAppSetting, setUserSetting } from '../helpers/factories/settings';
|
||||
import { makeShareToken } from '../helpers/factories/trips';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
import { BucketList } from '../../src/db/entities/BucketList.entity';
|
||||
import { BudgetItems } from '../../src/db/entities/BudgetItems.entity';
|
||||
import { Categories } from '../../src/db/entities/Categories.entity';
|
||||
import { CollabNotes } from '../../src/db/entities/CollabNotes.entity';
|
||||
import { InviteTokens } from '../../src/db/entities/InviteTokens.entity';
|
||||
import { JourneyContributors } from '../../src/db/entities/JourneyContributors.entity';
|
||||
import { JourneyEntries } from '../../src/db/entities/JourneyEntries.entity';
|
||||
import { JourneyShareTokens } from '../../src/db/entities/JourneyShareTokens.entity';
|
||||
import { Journeys } from '../../src/db/entities/Journeys.entity';
|
||||
import { McpTokens } from '../../src/db/entities/McpTokens.entity';
|
||||
import { NotificationChannelPreferences } from '../../src/db/entities/NotificationChannelPreferences.entity';
|
||||
import { Notifications } from '../../src/db/entities/Notifications.entity';
|
||||
import { OauthClients } from '../../src/db/entities/OauthClients.entity';
|
||||
import { OauthConsents } from '../../src/db/entities/OauthConsents.entity';
|
||||
import { OauthTokens } from '../../src/db/entities/OauthTokens.entity';
|
||||
import { PackingBags } from '../../src/db/entities/PackingBags.entity';
|
||||
import { PackingTemplates } from '../../src/db/entities/PackingTemplates.entity';
|
||||
import { PasswordResetTokens } from '../../src/db/entities/PasswordResetTokens.entity';
|
||||
import { Settings } from '../../src/db/entities/Settings.entity';
|
||||
import { ShareTokens } from '../../src/db/entities/ShareTokens.entity';
|
||||
import { Tags } from '../../src/db/entities/Tags.entity';
|
||||
import { TodoItems } from '../../src/db/entities/TodoItems.entity';
|
||||
import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity';
|
||||
import { TripFiles } from '../../src/db/entities/TripFiles.entity';
|
||||
import { TripMembers } from '../../src/db/entities/TripMembers.entity';
|
||||
import { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
|
||||
import { Trips } from '../../src/db/entities/Trips.entity';
|
||||
import { UserNoticeDismissals } from '../../src/db/entities/UserNoticeDismissals.entity';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
import { VacayPlanMembers } from '../../src/db/entities/VacayPlanMembers.entity';
|
||||
import { VacayPlans } from '../../src/db/entities/VacayPlans.entity';
|
||||
import { VisitedCountries } from '../../src/db/entities/VisitedCountries.entity';
|
||||
import { VisitedRegions } from '../../src/db/entities/VisitedRegions.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: MikroORM;
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
/** The row matching `where`, or null once it is gone. */
|
||||
function rowOf<T extends object>(entity: EntityClass<T>, where: FilterQuery<T>) {
|
||||
return findRow(orm, entity, where);
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
// Reset rate limiter state between tests so they don't interfere
|
||||
@@ -148,7 +195,7 @@ describe('Registration', () => {
|
||||
|
||||
it('AUTH-009 — registration disabled by admin returns 403', async () => {
|
||||
createUser(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
await setAppSetting(orm, 'allow_registration', 'false');
|
||||
const res = await request(app).post('/api/auth/register').send({
|
||||
username: 'blocked',
|
||||
email: 'blocked@example.com',
|
||||
@@ -160,7 +207,7 @@ describe('Registration', () => {
|
||||
|
||||
it('AUTH-010 — registration with valid invite token succeeds even when registration disabled', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
await setAppSetting(orm, 'allow_registration', 'false');
|
||||
const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id });
|
||||
|
||||
const res = await request(app).post('/api/auth/register').send({
|
||||
@@ -171,7 +218,7 @@ describe('Registration', () => {
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = testDb.prepare('SELECT used_count FROM invite_tokens WHERE id = ?').get(invite.id) as { used_count: number };
|
||||
const row = (await rowOf(InviteTokens, { id: invite.id }))!;
|
||||
expect(row.used_count).toBe(1);
|
||||
});
|
||||
|
||||
@@ -189,7 +236,7 @@ describe('Registration', () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const invite = createInviteToken(testDb, { max_uses: 1, created_by: admin.id });
|
||||
// Mark as exhausted
|
||||
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
|
||||
await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 });
|
||||
|
||||
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
|
||||
expect(res.status).toBe(410);
|
||||
@@ -199,7 +246,7 @@ describe('Registration', () => {
|
||||
it('AUTH-013 — GET /api/auth/invite/:token for a never-expiring invite keeps expires_at present and null on the wire (rule 16, task-5-review F1/T1)', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
const invite = createInviteToken(testDb, { max_uses: 3, created_by: admin.id });
|
||||
testDb.prepare('UPDATE invite_tokens SET used_count = 1 WHERE id = ?').run(invite.id);
|
||||
await updateRows(orm, InviteTokens, { id: invite.id }, { used_count: 1 });
|
||||
|
||||
const res = await request(app).get(`/api/auth/invite/${invite.token}`);
|
||||
expect(res.status).toBe(200);
|
||||
@@ -221,7 +268,7 @@ describe('Registration — whitespace normalization', () => {
|
||||
password: 'Str0ng!Pass',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const row = testDb.prepare('SELECT username FROM users WHERE email = ?').get('trimmed@example.com') as { username: string };
|
||||
const row = (await rowOf(Users, { email: 'trimmed@example.com' }))!;
|
||||
expect(row.username).toBe('trimmeduser');
|
||||
});
|
||||
|
||||
@@ -232,7 +279,7 @@ describe('Registration — whitespace normalization', () => {
|
||||
password: 'Str0ng!Pass',
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
const row = testDb.prepare('SELECT email FROM users WHERE username = ?').get('emailtrimuser') as { email: string };
|
||||
const row = (await rowOf(Users, { username: 'emailtrimuser' }))!;
|
||||
expect(row.email).toBe('emailtrim@example.com');
|
||||
});
|
||||
|
||||
@@ -326,7 +373,7 @@ describe('Session', () => {
|
||||
|
||||
it('AUTH-021 — user with must_change_password=1 sees the flag in their profile', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { must_change_password: 1 });
|
||||
|
||||
const res = await request(app).get('/api/auth/me').set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
@@ -351,7 +398,7 @@ describe('App config', () => {
|
||||
|
||||
it('AUTH-028 — allow_registration is false after admin disables it', async () => {
|
||||
createUser(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
await setAppSetting(orm, 'allow_registration', 'false');
|
||||
const res = await request(app).get('/api/auth/app-config');
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.allow_registration).toBe(false);
|
||||
@@ -370,9 +417,7 @@ describe('Demo login', () => {
|
||||
});
|
||||
|
||||
it('AUTH-022 — POST /api/auth/demo-login with DEMO_MODE and demo user returns 200 + cookie', async () => {
|
||||
testDb.prepare(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@trek.app', 'x', 'user')"
|
||||
).run();
|
||||
await insertRow(orm, Users, { username: 'demo', email: 'demo@trek.app', password_hash: 'x', role: 'user' });
|
||||
process.env.DEMO_MODE = 'true';
|
||||
try {
|
||||
const res = await request(app).post('/api/auth/demo-login');
|
||||
@@ -478,7 +523,7 @@ describe('MFA', () => {
|
||||
describe('Forced MFA policy', () => {
|
||||
it('AUTH-020 — non-MFA user is blocked (403 MFA_REQUIRED) when require_mfa is true', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
await setAppSetting(orm, 'require_mfa', 'true');
|
||||
|
||||
// mfaPolicy checks Authorization: Bearer header
|
||||
const res = await request(app).get('/api/trips').set(authHeader(user.id));
|
||||
@@ -488,7 +533,7 @@ describe('Forced MFA policy', () => {
|
||||
|
||||
it('AUTH-020 — /api/auth/me and MFA setup endpoints are exempt from require_mfa', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
await setAppSetting(orm, 'require_mfa', 'true');
|
||||
|
||||
const meRes = await request(app).get('/api/auth/me').set(authHeader(user.id));
|
||||
expect(meRes.status).toBe(200);
|
||||
@@ -499,7 +544,7 @@ describe('Forced MFA policy', () => {
|
||||
|
||||
it('AUTH-020 — MFA-enabled user passes through require_mfa policy', async () => {
|
||||
const { user } = createUserWithMfa(testDb);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
await setAppSetting(orm, 'require_mfa', 'true');
|
||||
|
||||
const res = await request(app).get('/api/trips').set(authHeader(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
@@ -512,7 +557,7 @@ describe('Forced MFA policy', () => {
|
||||
// leave every addon endpoint reachable without MFA.
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
await setAppSetting(orm, 'require_mfa', 'true');
|
||||
|
||||
for (const path of [`/api/trips/${trip.id}/budget`, `/api/trips/${trip.id}/packing`, `/api/trips/${trip.id}/todo`]) {
|
||||
const res = await request(app).get(path).set(authHeader(user.id));
|
||||
@@ -524,7 +569,7 @@ describe('Forced MFA policy', () => {
|
||||
it('AUTH-020 — MFA-enabled user reaches nested Nest addon controllers under require_mfa', async () => {
|
||||
const { user } = createUserWithMfa(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
testDb.prepare("INSERT INTO app_settings (key, value) VALUES ('require_mfa', 'true')").run();
|
||||
await setAppSetting(orm, 'require_mfa', 'true');
|
||||
|
||||
const res = await request(app).get(`/api/trips/${trip.id}/budget`).set(authHeader(user.id));
|
||||
expect(res.status).toBe(200);
|
||||
@@ -589,8 +634,7 @@ describe('Extended auth scenarios', () => {
|
||||
// Generate and store backup codes on the MFA-enabled user
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupHashes = backupCodes.map(hashBackupCode);
|
||||
testDb.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?')
|
||||
.run(JSON.stringify(backupHashes), user.id);
|
||||
await updateRows(orm, Users, { id: user.id }, { mfa_backup_codes: JSON.stringify(backupHashes) });
|
||||
|
||||
// Step 1: login to get mfa_token
|
||||
const loginRes = await request(app)
|
||||
@@ -633,14 +677,14 @@ describe('Account deletion', () => {
|
||||
// trip_members.invited_by: target invited thirdUser to otherUser's trip
|
||||
// (trip survives deletion; only invited_by should become NULL)
|
||||
const otherTrip = createTrip(testDb, otherUser.id);
|
||||
testDb.prepare('INSERT INTO trip_members (trip_id, user_id, invited_by) VALUES (?, ?, ?)').run(otherTrip.id, thirdUser.id, target.id);
|
||||
await insertRow(orm, TripMembers, { trip: otherTrip.id, user: thirdUser.id, invitedByRef: target.id });
|
||||
|
||||
// share_tokens.created_by: target created a share token for otherUser's trip
|
||||
testDb.prepare("INSERT INTO share_tokens (trip_id, token, created_by) VALUES (?, 'tok-auth-test', ?)").run(otherTrip.id, target.id);
|
||||
await makeShareToken(orm, otherTrip.id, target.id, { token: 'tok-auth-test' });
|
||||
|
||||
// budget_items.paid_by_user_id: target paid for an expense on otherUser's trip
|
||||
const budgetItem = createBudgetItem(testDb, otherTrip.id);
|
||||
testDb.prepare('UPDATE budget_items SET paid_by_user_id = ? WHERE id = ?').run(target.id, budgetItem.id);
|
||||
await updateRows(orm, BudgetItems, { id: budgetItem.id }, { paidByUser: target.id });
|
||||
|
||||
// journey_contributors: target is a contributor on otherUser's journey
|
||||
const otherJourney = createJourney(testDb, otherUser.id);
|
||||
@@ -650,35 +694,31 @@ describe('Account deletion', () => {
|
||||
createJourneyEntry(testDb, otherJourney.id, target.id);
|
||||
|
||||
// journey_share_tokens: target created a share token for otherUser's journey
|
||||
testDb.prepare("INSERT INTO journey_share_tokens (journey_id, token, created_by) VALUES (?, 'jst-auth-test', ?)").run(otherJourney.id, target.id);
|
||||
await insertRow(orm, JourneyShareTokens, { journey: otherJourney.id, token: 'jst-auth-test', createdByRef: target.id });
|
||||
|
||||
// notifications.sender_id (SET NULL): target sent a notification to otherUser
|
||||
const sentNotif = testDb.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||
).run(otherTrip.id, target.id, otherUser.id);
|
||||
const sentNotifId = await insertRow(orm, Notifications, {
|
||||
type: 'simple', scope: 'trip', target: otherTrip.id, sender: target.id, recipient: otherUser.id, title_key: 'k', text_key: 'k',
|
||||
});
|
||||
// notifications.recipient_id (CASCADE): otherUser sent a notification to target
|
||||
testDb.prepare(
|
||||
"INSERT INTO notifications (type, scope, target, sender_id, recipient_id, title_key, text_key) VALUES ('simple', 'trip', ?, ?, ?, 'k', 'k')"
|
||||
).run(otherTrip.id, otherUser.id, target.id);
|
||||
await insertRow(orm, Notifications, {
|
||||
type: 'simple', scope: 'trip', target: otherTrip.id, sender: otherUser.id, recipient: target.id, title_key: 'k', text_key: 'k',
|
||||
});
|
||||
|
||||
// user_notice_dismissals (CASCADE): target dismissed a notice
|
||||
testDb.prepare(
|
||||
"INSERT INTO user_notice_dismissals (user_id, notice_id, dismissed_at) VALUES (?, 'test-notice', ?)"
|
||||
).run(target.id, Date.now());
|
||||
await insertRow(orm, UserNoticeDismissals, { user: target.id, notice_id: 'test-notice', dismissed_at: Date.now() });
|
||||
|
||||
// owned journey: target owns a journey with an entry (cascade-deletes on journey deletion)
|
||||
const ownedJourney = createJourney(testDb, target.id);
|
||||
createJourneyEntry(testDb, ownedJourney.id, target.id);
|
||||
|
||||
// trip_files.uploaded_by (SET NULL): target uploaded a file to otherUser's trip
|
||||
const fileRow = testDb.prepare(
|
||||
"INSERT INTO trip_files (trip_id, filename, original_name, uploaded_by) VALUES (?, 'f.pdf', 'file.pdf', ?)"
|
||||
).run(otherTrip.id, target.id);
|
||||
const fileId = await insertRow(orm, TripFiles, {
|
||||
trip: otherTrip.id, filename: 'f.pdf', original_name: 'file.pdf', uploadedByRef: target.id,
|
||||
});
|
||||
|
||||
// trek_photos.owner_id (SET NULL): target owns a photo in the central registry
|
||||
const trekPhotoRow = testDb.prepare(
|
||||
"INSERT INTO trek_photos (provider, asset_id, owner_id) VALUES ('immich', 'asset-auth-test', ?)"
|
||||
).run(target.id);
|
||||
const trekPhotoId = await insertRow(orm, TrekPhotos, { provider: 'immich', asset_id: 'asset-auth-test', owner: target.id });
|
||||
|
||||
// trip_photos.user_id (CASCADE): target added a photo to otherUser's trip
|
||||
addTripPhoto(testDb, otherTrip.id, target.id, 'asset-tp-auth', 'immich');
|
||||
@@ -697,33 +737,31 @@ describe('Account deletion', () => {
|
||||
|
||||
// todo_items.assigned_user_id (SET NULL): target is assigned to a todo on otherUser's trip
|
||||
const todoItem = createTodoItem(testDb, otherTrip.id);
|
||||
testDb.prepare('UPDATE todo_items SET assigned_user_id = ? WHERE id = ?').run(target.id, todoItem.id);
|
||||
await updateRows(orm, TodoItems, { id: todoItem.id }, { assignedUser: target.id });
|
||||
|
||||
// packing_bags.user_id (SET NULL): target owns a packing bag on otherUser's trip
|
||||
const packBagRow = testDb.prepare(
|
||||
"INSERT INTO packing_bags (trip_id, name, color, user_id) VALUES (?, 'Bag', '#ff0000', ?)"
|
||||
).run(otherTrip.id, target.id);
|
||||
const packBagId = await insertRow(orm, PackingBags, { trip: otherTrip.id, name: 'Bag', color: '#ff0000', user: target.id });
|
||||
|
||||
// mcp_tokens.user_id (CASCADE): target has an MCP API token
|
||||
createMcpToken(testDb, target.id);
|
||||
|
||||
// oauth_tokens/consents.user_id (CASCADE): target has tokens from otherUser's OAuth client
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_clients (id, user_id, name, client_id, client_secret_hash) VALUES ('cl-auth-test', ?, 'App', 'cid-auth-test', 'h')"
|
||||
).run(otherUser.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_tokens (client_id, user_id, access_token_hash, refresh_token_hash, access_token_expires_at, refresh_token_expires_at) VALUES ('cid-auth-test', ?, 'ath-auth', 'rth-auth', datetime('now','+1 hour'), datetime('now','+30 days'))"
|
||||
).run(target.id);
|
||||
testDb.prepare(
|
||||
"INSERT INTO oauth_consents (client_id, user_id) VALUES ('cid-auth-test', ?)"
|
||||
).run(target.id);
|
||||
await insertRow(orm, OauthClients, {
|
||||
id: 'cl-auth-test', user: otherUser.id, name: 'App', client_id: 'cid-auth-test', client_secret_hash: 'h',
|
||||
});
|
||||
await insertRow(orm, OauthTokens, {
|
||||
client: 'cid-auth-test', user: target.id, access_token_hash: 'ath-auth', refresh_token_hash: 'rth-auth',
|
||||
access_token_expires_at: dbNow(new Date(Date.now() + 3600_000)),
|
||||
refresh_token_expires_at: dbNow(new Date(Date.now() + 30 * 86_400_000)),
|
||||
});
|
||||
await insertRow(orm, OauthConsents, { client: 'cid-auth-test', user: target.id });
|
||||
|
||||
// vacay_plans.owner_id (CASCADE): target owns a vacation plan
|
||||
const vacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(target.id);
|
||||
const vacayPlanId = await insertRow(orm, VacayPlans, { owner: target.id });
|
||||
|
||||
// vacay_plan_members.user_id (CASCADE): target is a member of otherUser's vacay plan
|
||||
const otherVacayPlanRow = testDb.prepare("INSERT INTO vacay_plans (owner_id) VALUES (?)").run(otherUser.id);
|
||||
testDb.prepare("INSERT INTO vacay_plan_members (plan_id, user_id) VALUES (?, ?)").run(otherVacayPlanRow.lastInsertRowid, target.id);
|
||||
const otherVacayPlanId = await insertRow(orm, VacayPlans, { owner: otherUser.id });
|
||||
await insertRow(orm, VacayPlanMembers, { plan: otherVacayPlanId, user: target.id });
|
||||
|
||||
// bucket_list.user_id (CASCADE): target has a bucket list item
|
||||
createBucketListItem(testDb, target.id);
|
||||
@@ -732,14 +770,10 @@ describe('Account deletion', () => {
|
||||
createVisitedCountry(testDb, target.id, 'JP');
|
||||
|
||||
// visited_regions.user_id (CASCADE): target has visited a region
|
||||
testDb.prepare(
|
||||
"INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, 'JP-13', 'Tokyo', 'JP')"
|
||||
).run(target.id);
|
||||
await insertRow(orm, VisitedRegions, { user: target.id, region_code: 'JP-13', region_name: 'Tokyo', country_code: 'JP' });
|
||||
|
||||
// packing_templates.created_by (CASCADE): target created a packing template
|
||||
const packTemplateRow = testDb.prepare(
|
||||
"INSERT INTO packing_templates (name, created_by) VALUES ('My Template', ?)"
|
||||
).run(target.id);
|
||||
const packTemplateId = await insertRow(orm, PackingTemplates, { name: 'My Template', createdByRef: target.id });
|
||||
|
||||
// invite_tokens.created_by (CASCADE): target created an invite token
|
||||
createInviteToken(testDb, { created_by: target.id });
|
||||
@@ -748,20 +782,18 @@ describe('Account deletion', () => {
|
||||
createCollabNote(testDb, otherTrip.id, target.id);
|
||||
|
||||
// settings.user_id (CASCADE): target has a user setting
|
||||
testDb.prepare("INSERT INTO settings (user_id, key, value) VALUES (?, 'theme', 'dark')").run(target.id);
|
||||
await setUserSetting(orm, target.id, 'theme', 'dark');
|
||||
|
||||
// password_reset_tokens.user_id (CASCADE): target has a pending password reset
|
||||
testDb.prepare(
|
||||
"INSERT INTO password_reset_tokens (user_id, token_hash, expires_at) VALUES (?, 'prt-hash-auth', datetime('now','+1 hour'))"
|
||||
).run(target.id);
|
||||
await insertRow(orm, PasswordResetTokens, {
|
||||
user: target.id, token_hash: 'prt-hash-auth', expires_at: dbNow(new Date(Date.now() + 3600_000)),
|
||||
});
|
||||
|
||||
// audit_log.user_id (SET NULL): target performed an audited action
|
||||
const auditRow = testDb.prepare(
|
||||
"INSERT INTO audit_log (user_id, action, ip) VALUES (?, 'test.action', '127.0.0.1')"
|
||||
).run(target.id);
|
||||
const auditId = await insertRow(orm, AuditLog, { user: target.id, action: 'test.action', ip: '127.0.0.1' });
|
||||
|
||||
// notification_channel_preferences.user_id (CASCADE): target has notification preferences
|
||||
testDb.prepare("INSERT OR IGNORE INTO notification_channel_preferences (user_id, event_type, channel) VALUES (?, 'trip_invite', 'email')").run(target.id);
|
||||
await insertRowIgnoringConflict(orm, NotificationChannelPreferences, { user: target.id, event_type: 'trip_invite', channel: 'email' });
|
||||
|
||||
// admin exists to ensure target (non-admin user) passes the last-admin guard
|
||||
void admin;
|
||||
@@ -772,69 +804,69 @@ describe('Account deletion', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(Users, { id: target.id })).toBeNull();
|
||||
// trip_members row survives but invited_by is now NULL
|
||||
expect((testDb.prepare('SELECT invited_by FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, thirdUser.id) as any).invited_by).toBeNull();
|
||||
expect(testDb.prepare('SELECT id FROM share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
expect((testDb.prepare('SELECT paid_by_user_id FROM budget_items WHERE id = ?').get(budgetItem.id) as any).paid_by_user_id).toBeNull();
|
||||
expect(testDb.prepare('SELECT user_id FROM journey_contributors WHERE journey_id = ? AND user_id = ?').get(otherJourney.id, target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_entries WHERE author_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_share_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
expect((await rowOf(TripMembers, { trip: otherTrip.id, user: thirdUser.id }))!.invited_by).toBeNull();
|
||||
expect(await rowOf(ShareTokens, { createdByRef: target.id })).toBeNull();
|
||||
expect((await rowOf(BudgetItems, { id: budgetItem.id }))!.paid_by_user_id).toBeNull();
|
||||
expect(await rowOf(JourneyContributors, { journey: otherJourney.id, user: target.id })).toBeNull();
|
||||
expect(await rowOf(JourneyEntries, { author: target.id })).toBeNull();
|
||||
expect(await rowOf(JourneyShareTokens, { createdByRef: target.id })).toBeNull();
|
||||
// sent notification survives but sender_id becomes NULL
|
||||
expect((testDb.prepare('SELECT sender_id FROM notifications WHERE id = ?').get(sentNotif.lastInsertRowid) as any).sender_id).toBeNull();
|
||||
expect((await rowOf(Notifications, { id: sentNotifId }))!.sender_id).toBeNull();
|
||||
// received notification is cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM notifications WHERE recipient_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(Notifications, { recipient: target.id })).toBeNull();
|
||||
// notice dismissals are cascade-deleted
|
||||
expect(testDb.prepare("SELECT user_id FROM user_notice_dismissals WHERE user_id = ? AND notice_id = 'test-notice'").get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(UserNoticeDismissals, { user: target.id, notice_id: 'test-notice' })).toBeNull();
|
||||
// owned journey and its entries are cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM journeys WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM journey_entries WHERE journey_id = ?').get(ownedJourney.id)).toBeUndefined();
|
||||
expect(await rowOf(Journeys, { user: target.id })).toBeNull();
|
||||
expect(await rowOf(JourneyEntries, { journey: ownedJourney.id })).toBeNull();
|
||||
// uploaded file survives but uploaded_by is now NULL
|
||||
expect((testDb.prepare('SELECT uploaded_by FROM trip_files WHERE id = ?').get(fileRow.lastInsertRowid) as any).uploaded_by).toBeNull();
|
||||
expect((await rowOf(TripFiles, { id: fileId }))!.uploaded_by).toBeNull();
|
||||
// trek_photos row survives but owner_id is now NULL
|
||||
expect((testDb.prepare('SELECT owner_id FROM trek_photos WHERE id = ?').get(trekPhotoRow.lastInsertRowid) as any).owner_id).toBeNull();
|
||||
expect((await rowOf(TrekPhotos, { id: trekPhotoId }))!.owner_id).toBeNull();
|
||||
// trip_photos row for target is cascade-deleted
|
||||
expect(testDb.prepare("SELECT id FROM trip_photos WHERE trip_id = ? AND user_id = ?").get(otherTrip.id, target.id)).toBeUndefined();
|
||||
expect(await rowOf(TripPhotos, { trip: otherTrip.id, user: target.id })).toBeNull();
|
||||
// owned trip is cascade-deleted
|
||||
expect(testDb.prepare('SELECT id FROM trips WHERE id = ?').get(ownedTrip.id)).toBeUndefined();
|
||||
expect(await rowOf(Trips, { id: ownedTrip.id })).toBeNull();
|
||||
// trip membership on others' trips is removed
|
||||
expect(testDb.prepare('SELECT id FROM trip_members WHERE trip_id = ? AND user_id = ?').get(otherTrip.id, target.id)).toBeUndefined();
|
||||
expect(await rowOf(TripMembers, { trip: otherTrip.id, user: target.id })).toBeNull();
|
||||
// category survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM categories WHERE id = ?').get(userCategory.id) as any).user_id).toBeNull();
|
||||
expect((await rowOf(Categories, { id: userCategory.id }))!.user_id).toBeNull();
|
||||
// tag is deleted
|
||||
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(userTag.id)).toBeUndefined();
|
||||
expect(await rowOf(Tags, { id: userTag.id })).toBeNull();
|
||||
// todo assigned_user_id is NULL
|
||||
expect((testDb.prepare('SELECT assigned_user_id FROM todo_items WHERE id = ?').get(todoItem.id) as any).assigned_user_id).toBeNull();
|
||||
expect((await rowOf(TodoItems, { id: todoItem.id }))!.assigned_user_id).toBeNull();
|
||||
// packing bag survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM packing_bags WHERE id = ?').get(packBagRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||
expect((await rowOf(PackingBags, { id: packBagId }))!.user_id).toBeNull();
|
||||
// MCP tokens are deleted
|
||||
expect(testDb.prepare('SELECT id FROM mcp_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(McpTokens, { user: target.id })).toBeNull();
|
||||
// OAuth tokens and consents are deleted
|
||||
expect(testDb.prepare('SELECT id FROM oauth_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM oauth_consents WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(OauthTokens, { user: target.id })).toBeNull();
|
||||
expect(await rowOf(OauthConsents, { user: target.id })).toBeNull();
|
||||
// owned vacay plan is deleted
|
||||
expect(testDb.prepare('SELECT id FROM vacay_plans WHERE id = ?').get(vacayPlanRow.lastInsertRowid)).toBeUndefined();
|
||||
expect(await rowOf(VacayPlans, { id: vacayPlanId })).toBeNull();
|
||||
// vacay plan membership on others' plans is removed
|
||||
expect(testDb.prepare('SELECT id FROM vacay_plan_members WHERE plan_id = ? AND user_id = ?').get(otherVacayPlanRow.lastInsertRowid, target.id)).toBeUndefined();
|
||||
expect(await rowOf(VacayPlanMembers, { plan: otherVacayPlanId, user: target.id })).toBeNull();
|
||||
// bucket list items are deleted
|
||||
expect(testDb.prepare('SELECT id FROM bucket_list WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(BucketList, { user: target.id })).toBeNull();
|
||||
// travel history is deleted
|
||||
expect(testDb.prepare('SELECT user_id FROM visited_countries WHERE user_id = ? AND country_code = ?').get(target.id, 'JP')).toBeUndefined();
|
||||
expect(testDb.prepare('SELECT id FROM visited_regions WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(VisitedCountries, { user: target.id, country_code: 'JP' })).toBeNull();
|
||||
expect(await rowOf(VisitedRegions, { user: target.id })).toBeNull();
|
||||
// packing template is deleted
|
||||
expect(testDb.prepare('SELECT id FROM packing_templates WHERE id = ?').get(packTemplateRow.lastInsertRowid)).toBeUndefined();
|
||||
expect(await rowOf(PackingTemplates, { id: packTemplateId })).toBeNull();
|
||||
// invite tokens created by target are deleted
|
||||
expect(testDb.prepare('SELECT id FROM invite_tokens WHERE created_by = ?').get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(InviteTokens, { createdByRef: target.id })).toBeNull();
|
||||
// collab content is deleted
|
||||
expect(testDb.prepare('SELECT id FROM collab_notes WHERE user_id = ? AND trip_id = ?').get(target.id, otherTrip.id)).toBeUndefined();
|
||||
expect(await rowOf(CollabNotes, { user: target.id, trip: otherTrip.id })).toBeNull();
|
||||
// user settings are deleted
|
||||
expect(testDb.prepare("SELECT id FROM settings WHERE user_id = ?").get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(Settings, { user: target.id })).toBeNull();
|
||||
// password reset tokens are deleted
|
||||
expect(testDb.prepare('SELECT id FROM password_reset_tokens WHERE user_id = ?').get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(PasswordResetTokens, { user: target.id })).toBeNull();
|
||||
// audit log entry survives but user_id is NULL
|
||||
expect((testDb.prepare('SELECT user_id FROM audit_log WHERE id = ?').get(auditRow.lastInsertRowid) as any).user_id).toBeNull();
|
||||
expect((await rowOf(AuditLog, { id: auditId }))!.user_id).toBeNull();
|
||||
// notification channel preferences are deleted
|
||||
expect(testDb.prepare("SELECT user_id FROM notification_channel_preferences WHERE user_id = ? AND event_type = 'trip_invite'").get(target.id)).toBeUndefined();
|
||||
expect(await rowOf(NotificationChannelPreferences, { user: target.id, event_type: 'trip_invite' })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,15 +27,30 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createBudgetItem, addTripMember, createReservation } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { deleteRows, findRow, insertRow } from '../helpers/factories/rows';
|
||||
import { setAppSetting } from '../helpers/factories/settings';
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
import { BudgetItems } from '../../src/db/entities/BudgetItems.entity';
|
||||
import { Reservations } from '../../src/db/entities/Reservations.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: MikroORM;
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
/** A hotel expense linked to the booking. */
|
||||
function seedBookedExpense(tripId: number, reservationId: number, totalPrice: number): Promise<number> {
|
||||
return insertRow(orm, BudgetItems, {
|
||||
trip: tripId, name: 'Hotel Cost', category: 'Accommodation', total_price: totalPrice, reservation: reservationId,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
await resetRateLimits(nestApp);
|
||||
@@ -187,18 +202,15 @@ describe('Delete budget item', () => {
|
||||
const trip = createTrip(testDb, user.id);
|
||||
const reservation = createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
|
||||
|
||||
const result = testDb.prepare(
|
||||
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(trip.id, 'Hotel Cost', 'Accommodation', 250, reservation.id);
|
||||
const itemId = result.lastInsertRowid as number;
|
||||
const itemId = await seedBookedExpense(trip.id, reservation.id, 250);
|
||||
|
||||
const del = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/budget/${itemId}`)
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(del.status).toBe(200);
|
||||
|
||||
const reservationAfter = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id);
|
||||
expect(reservationAfter).toBeDefined();
|
||||
const reservationAfter = await findRow(orm, Reservations, { id: reservation.id });
|
||||
expect(reservationAfter).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -402,7 +414,7 @@ describe('Reorder budget items', () => {
|
||||
const item = createBudgetItem(testDb, trip.id);
|
||||
|
||||
// Restrict budget_edit to trip_owner only
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_budget_edit', 'trip_owner')").run();
|
||||
await setAppSetting(orm, 'perm_budget_edit', 'trip_owner');
|
||||
const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache');
|
||||
await invalidatePermissionsCache();
|
||||
|
||||
@@ -413,7 +425,7 @@ describe('Reorder budget items', () => {
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
// Restore default
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run();
|
||||
await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' });
|
||||
await invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
@@ -475,10 +487,7 @@ describe('Reservation price sync on budget item update', () => {
|
||||
const reservation = createReservation(testDb, trip.id, { title: 'Hotel Booking', type: 'hotel' });
|
||||
|
||||
// Create a budget item linked to the reservation
|
||||
const result = testDb.prepare(
|
||||
'INSERT INTO budget_items (trip_id, name, category, total_price, reservation_id) VALUES (?, ?, ?, ?, ?)'
|
||||
).run(trip.id, 'Hotel Cost', 'Accommodation', 200, reservation.id);
|
||||
const itemId = result.lastInsertRowid as number;
|
||||
const itemId = await seedBookedExpense(trip.id, reservation.id, 200);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/trips/${trip.id}/budget/${itemId}`)
|
||||
@@ -488,8 +497,8 @@ describe('Reservation price sync on budget item update', () => {
|
||||
expect(res.body.item.total_price).toBe(350);
|
||||
|
||||
// Verify reservation metadata was synced
|
||||
const updatedReservation = testDb.prepare('SELECT metadata FROM reservations WHERE id = ?').get(reservation.id) as { metadata: string | null } | undefined;
|
||||
expect(updatedReservation).toBeDefined();
|
||||
const updatedReservation = await findRow(orm, Reservations, { id: reservation.id });
|
||||
expect(updatedReservation).not.toBeNull();
|
||||
const meta = JSON.parse(updatedReservation!.metadata || '{}');
|
||||
expect(meta.price).toBe('350');
|
||||
});
|
||||
@@ -507,7 +516,7 @@ describe('Budget edit permission enforcement', () => {
|
||||
addTripMember(testDb, trip.id, member.id);
|
||||
|
||||
const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache');
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_budget_edit', 'trip_owner')").run();
|
||||
await setAppSetting(orm, 'perm_budget_edit', 'trip_owner');
|
||||
await invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
@@ -516,7 +525,7 @@ describe('Budget edit permission enforcement', () => {
|
||||
.send({ name: 'Sneaky Expense', total_price: 100 });
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run();
|
||||
await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' });
|
||||
await invalidatePermissionsCache();
|
||||
});
|
||||
|
||||
@@ -528,7 +537,7 @@ describe('Budget edit permission enforcement', () => {
|
||||
createBudgetItem(testDb, trip.id, { name: 'Item', category: 'Transport' });
|
||||
|
||||
const { invalidatePermissionsCache } = await import('../../src/nest/permissions/permissions-cache');
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('perm_budget_edit', 'trip_owner')").run();
|
||||
await setAppSetting(orm, 'perm_budget_edit', 'trip_owner');
|
||||
await invalidatePermissionsCache();
|
||||
|
||||
const res = await request(app)
|
||||
@@ -537,7 +546,7 @@ describe('Budget edit permission enforcement', () => {
|
||||
.send({ orderedCategories: ['Transport'] });
|
||||
expect(res.status).toBe(403);
|
||||
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key = 'perm_budget_edit'").run();
|
||||
await deleteRows(orm, AppSettings, { key: 'perm_budget_edit' });
|
||||
await invalidatePermissionsCache();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,22 +46,29 @@ import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createPlace, createJourney, linkTripToJourney } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { findRow, insertRow, upsertRow } from '../helpers/factories/rows';
|
||||
import { Addons } from '../../src/db/entities/Addons.entity';
|
||||
import { JourneyEntries } from '../../src/db/entities/JourneyEntries.entity';
|
||||
import { Places } from '../../src/db/entities/Places.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: MikroORM;
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
await resetRateLimits(nestApp);
|
||||
await invalidatePermissionsCache();
|
||||
// Enable the journey addon.
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)"
|
||||
).run();
|
||||
await upsertRow(orm, Addons, {
|
||||
id: 'journey', name: 'Journey', description: 'Travel journal', type: 'global', icon: 'Compass', enabled: true, sort_order: 35,
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
@@ -81,10 +88,10 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade',
|
||||
// annotated, rather than deleted outright (that path is for content-less
|
||||
// skeletons) or left dangling with a stale source_place_id.
|
||||
const now = Date.now();
|
||||
const entryId = testDb.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, story, entry_date, visibility, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'entry', ?, ?, '2026-01-15', 'private', 0, ?, ?)
|
||||
`).run(journey.id, trip.id, place.id, user.id, 'A café', 'Lovely coffee.', now, now).lastInsertRowid;
|
||||
const entryId = await insertRow(orm, JourneyEntries, {
|
||||
journey: journey.id, sourceTrip: trip.id, sourcePlace: place.id, author: user.id, type: 'entry', title: 'A café',
|
||||
story: 'Lovely coffee.', entry_date: '2026-01-15', visibility: 'private', sort_order: 0, created_at: now, updated_at: now,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
@@ -93,17 +100,12 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade',
|
||||
expect(res.body).toEqual({ success: true, tourPlaceIds: [] });
|
||||
|
||||
// The place is gone.
|
||||
expect(testDb.prepare('SELECT 1 FROM places WHERE id = ?').get(place.id)).toBeUndefined();
|
||||
expect(await findRow(orm, Places, { id: place.id })).toBeNull();
|
||||
|
||||
// The entry survives, detached and annotated — the FK's ON DELETE SET
|
||||
// NULL alone would null source_place_id but would never touch story or
|
||||
// type; only onPlaceDeleted having actually run produces the note.
|
||||
const entry = testDb.prepare('SELECT * FROM journey_entries WHERE id = ?').get(entryId) as {
|
||||
source_place_id: number | null;
|
||||
source_trip_id: number | null;
|
||||
type: string;
|
||||
story: string;
|
||||
};
|
||||
const entry = (await findRow(orm, JourneyEntries, { id: entryId }))!;
|
||||
expect(entry.source_place_id).toBeNull();
|
||||
expect(entry.source_trip_id).toBeNull();
|
||||
expect(entry.type).toBe('entry');
|
||||
@@ -118,10 +120,10 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade',
|
||||
linkTripToJourney(testDb, journey.id, trip.id);
|
||||
|
||||
const now = Date.now();
|
||||
const entryId = testDb.prepare(`
|
||||
INSERT INTO journey_entries (journey_id, source_trip_id, source_place_id, author_id, type, title, entry_date, visibility, sort_order, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'skeleton', ?, '2026-01-16', 'private', 0, ?, ?)
|
||||
`).run(journey.id, trip.id, place.id, user.id, place.name, now, now).lastInsertRowid;
|
||||
const entryId = await insertRow(orm, JourneyEntries, {
|
||||
journey: journey.id, sourceTrip: trip.id, sourcePlace: place.id, author: user.id, type: 'skeleton', title: place.name,
|
||||
entry_date: '2026-01-16', visibility: 'private', sort_order: 0, created_at: now, updated_at: now,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/trips/${trip.id}/places/${place.id}`)
|
||||
@@ -130,6 +132,6 @@ describe('deleting a place detaches its journey entry ahead of the FK cascade',
|
||||
|
||||
// onPlaceDeleted ran (and completed) as part of the request: the
|
||||
// content-less skeleton is gone, not merely detached.
|
||||
expect(testDb.prepare('SELECT 1 FROM journey_entries WHERE id = ?').get(entryId)).toBeUndefined();
|
||||
expect(await findRow(orm, JourneyEntries, { id: entryId })).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,22 +43,29 @@ import {
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { jpegWithExif } from '../helpers/exif-jpeg';
|
||||
import { invalidatePermissionsCache } from '../../src/nest/permissions/permissions-cache';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { findRow, upsertRow } from '../helpers/factories/rows';
|
||||
import { setAppSetting } from '../helpers/factories/settings';
|
||||
import { Addons } from '../../src/db/entities/Addons.entity';
|
||||
import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: MikroORM;
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
await resetRateLimits(nestApp);
|
||||
await invalidatePermissionsCache();
|
||||
// Enable the journey addon
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('journey', 'Journey', 'Travel journal', 'global', 'Compass', 1, 35)"
|
||||
).run();
|
||||
await upsertRow(orm, Addons, {
|
||||
id: 'journey', name: 'Journey', description: 'Travel journal', type: 'global', icon: 'Compass', enabled: true, sort_order: 35,
|
||||
});
|
||||
});
|
||||
afterAll(async () => {
|
||||
await nestApp.close();
|
||||
@@ -995,8 +1002,7 @@ describe('Provider photos — passphrase persistence', () => {
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('synologyphotos', 'shared-asset-1', user.id) as { passphrase: string | null } | undefined;
|
||||
const row = await findRow(orm, TrekPhotos, { provider: 'synologyphotos', asset_id: 'shared-asset-1', owner: user.id });
|
||||
expect(row?.passphrase).not.toBeNull();
|
||||
expect(typeof row?.passphrase).toBe('string');
|
||||
});
|
||||
@@ -1015,8 +1021,7 @@ describe('Provider photos — passphrase persistence', () => {
|
||||
expect(res.body.added).toBe(2);
|
||||
|
||||
for (const assetId of ['batch-asset-1', 'batch-asset-2']) {
|
||||
const row = testDb.prepare('SELECT passphrase FROM trek_photos WHERE provider = ? AND asset_id = ? AND owner_id = ?')
|
||||
.get('synologyphotos', assetId, user.id) as { passphrase: string | null } | undefined;
|
||||
const row = await findRow(orm, TrekPhotos, { provider: 'synologyphotos', asset_id: assetId, owner: user.id });
|
||||
expect(row?.passphrase).not.toBeNull();
|
||||
}
|
||||
});
|
||||
@@ -1078,7 +1083,7 @@ describe('Journey upload parity', () => {
|
||||
// The admin allowlist rejects the empty extension before the filename
|
||||
// fallback can run; the wildcard makes the fallback reachable so it is
|
||||
// actually pinned here.
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
await setAppSetting(orm, 'allowed_file_types', '*');
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/journeys/entries/${entry.id}/photos`)
|
||||
@@ -1218,8 +1223,9 @@ describe('Journey upload parity', () => {
|
||||
// The backfill runs detached after the response, so wait for it to land.
|
||||
async function captureOf(filePath: string): Promise<CaptureRow> {
|
||||
let row: CaptureRow | undefined;
|
||||
await vi.waitFor(() => {
|
||||
row = testDb.prepare('SELECT taken_at, lat, lng FROM trek_photos WHERE file_path = ?').get(filePath) as CaptureRow;
|
||||
await vi.waitFor(async () => {
|
||||
const photo = await findRow(orm, TrekPhotos, { file_path: filePath });
|
||||
row = photo ? { taken_at: photo.taken_at ?? null, lat: photo.lat ?? null, lng: photo.lng ?? null } : undefined;
|
||||
expect(row?.taken_at).toBeTruthy();
|
||||
});
|
||||
return row!;
|
||||
|
||||
@@ -45,6 +45,8 @@ import { UnitOfWork } from '../../src/nest/database/unit-of-work';
|
||||
import { createTestAddonsService } from '../helpers/test-addons';
|
||||
import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { upsertRow } from '../helpers/factories/rows';
|
||||
import { Addons } from '../../src/db/entities/Addons.entity';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
import { OauthClients } from '../../src/db/entities/OauthClients.entity';
|
||||
@@ -67,10 +69,10 @@ function makePkce() {
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
function setMcpEnabled(enabled: boolean) {
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)"
|
||||
).run(enabled ? 1 : 0);
|
||||
async function setMcpEnabled(enabled: boolean) {
|
||||
await upsertRow(t, Addons, {
|
||||
id: 'mcp', name: 'MCP', description: 'AI assistant integration', type: 'integration', icon: 'Terminal', enabled, sort_order: 12,
|
||||
});
|
||||
}
|
||||
|
||||
/** DCR-register a public client and return its client_id. */
|
||||
@@ -93,7 +95,7 @@ beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
await resetRateLimits(nestApp);
|
||||
setMcpEnabled(true);
|
||||
await setMcpEnabled(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -241,7 +243,7 @@ describe('GET /oauth/authorize — SDK authorizationHandler over trekOAuthProvid
|
||||
});
|
||||
|
||||
it('AUTHZ-007 — MCP addon off: authorize and register both 404 with empty bodies', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
const authz = await request(app).get('/oauth/authorize').query({ client_id: 'x' });
|
||||
expect(authz.status).toBe(404);
|
||||
expect(authz.text).toBe('');
|
||||
|
||||
@@ -42,6 +42,8 @@ import { UnitOfWork } from '../../src/nest/database/unit-of-work';
|
||||
import { createTestAddonsService } from '../helpers/test-addons';
|
||||
import { AuditService } from '../../src/nest/audit/audit.service';
|
||||
import { createTestOrm, type TestOrm } from '../helpers/test-orm';
|
||||
import { updateRows, upsertRow } from '../helpers/factories/rows';
|
||||
import { Addons } from '../../src/db/entities/Addons.entity';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
import { OauthClients } from '../../src/db/entities/OauthClients.entity';
|
||||
@@ -75,10 +77,10 @@ function makePkce() {
|
||||
// the legacy oauthService (addons.bridge) resolve the MCP addon from the real
|
||||
// addons row, so driving the DB row keeps mcpEnabled() AND
|
||||
// validateAuthorizeRequest()/token/revoke consistent from one source.
|
||||
function setMcpEnabled(enabled: boolean) {
|
||||
testDb.prepare(
|
||||
"INSERT OR REPLACE INTO addons (id, name, description, type, icon, enabled, sort_order) VALUES ('mcp', 'MCP', 'AI assistant integration', 'integration', 'Terminal', ?, 12)"
|
||||
).run(enabled ? 1 : 0);
|
||||
async function setMcpEnabled(enabled: boolean) {
|
||||
await upsertRow(t, Addons, {
|
||||
id: 'mcp', name: 'MCP', description: 'AI assistant integration', type: 'integration', icon: 'Terminal', enabled, sort_order: 12,
|
||||
});
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -91,7 +93,7 @@ beforeAll(async () => {
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
await resetRateLimits(nestApp);
|
||||
setMcpEnabled(true);
|
||||
await setMcpEnabled(true);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -234,7 +236,7 @@ describe('platform/discovery parity pins', () => {
|
||||
});
|
||||
|
||||
it('PLAT-PIN-004 — every /.well-known/* path 404s with an EMPTY body when MCP is disabled', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
for (const path of [
|
||||
'/.well-known/openid-configuration',
|
||||
'/.well-known/oauth-protected-resource',
|
||||
@@ -284,7 +286,7 @@ describe('POST /oauth/token — authorization_code grant', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-003 — MCP addon disabled returns 404', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
const res = await request(app)
|
||||
.post('/oauth/token')
|
||||
.send({ grant_type: 'authorization_code', client_id: 'x', client_secret: 'y', code: 'z', redirect_uri: 'https://r.example.com/cb', code_verifier: 'v' });
|
||||
@@ -646,7 +648,7 @@ describe('POST /oauth/revoke', () => {
|
||||
|
||||
describe('GET /api/oauth/authorize/validate', () => {
|
||||
it('OAUTH-019 — returns 404 when MCP addon disabled (M2: prevents feature fingerprinting)', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
const res = await request(app)
|
||||
.get('/api/oauth/authorize/validate')
|
||||
.query({ response_type: 'code', client_id: 'x', redirect_uri: 'https://r.example.com/cb', scope: 'trips:read', code_challenge: 'c', code_challenge_method: 'S256' });
|
||||
@@ -886,7 +888,7 @@ describe('POST /api/oauth/authorize', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-029 — 403 when MCP disabled', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -983,7 +985,7 @@ describe('POST /api/oauth/authorize', () => {
|
||||
|
||||
describe('Client CRUD — /api/oauth/clients', () => {
|
||||
it('OAUTH-033 — GET returns 403 when addon disabled', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -1020,7 +1022,7 @@ describe('Client CRUD — /api/oauth/clients', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-036 — POST returns 403 when addon disabled', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -1070,7 +1072,7 @@ describe('Client CRUD — /api/oauth/clients', () => {
|
||||
|
||||
describe('Sessions — /api/oauth/sessions', () => {
|
||||
it('OAUTH-040 — GET returns 403 when addon disabled', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -1148,7 +1150,7 @@ describe('Sessions — /api/oauth/sessions', () => {
|
||||
});
|
||||
|
||||
it('OAUTH-044 — DELETE /sessions/:id returns 403 when addon disabled', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
const { user } = createUser(testDb);
|
||||
|
||||
const res = await request(app)
|
||||
@@ -1173,13 +1175,13 @@ describe('M1 — Cache-Control headers on /oauth/token', () => {
|
||||
|
||||
describe('M2 — 404 when MCP disabled on discovery + revoke endpoints', () => {
|
||||
it('OAUTH-SEC-002 — /.well-known/oauth-authorization-server returns 404 when disabled', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
const res = await request(app).get('/.well-known/oauth-authorization-server');
|
||||
expect(res.status).toBe(404);
|
||||
});
|
||||
|
||||
it('OAUTH-SEC-003 — /oauth/revoke returns 404 when disabled', async () => {
|
||||
setMcpEnabled(false);
|
||||
await setMcpEnabled(false);
|
||||
const res = await request(app)
|
||||
.post('/oauth/revoke')
|
||||
.send({ token: 'x', client_id: 'y', client_secret: 'z' });
|
||||
@@ -1417,10 +1419,10 @@ describe('C3 — Refresh token replay detection', () => {
|
||||
* below still describes theft — a token used minutes later — rather than two
|
||||
* clients refreshing at the same moment.
|
||||
*/
|
||||
function agePastGrace(rawRefreshToken: string) {
|
||||
async function agePastGrace(rawRefreshToken: string) {
|
||||
const hash = crypto.createHash('sha256').update(rawRefreshToken).digest('hex');
|
||||
const old = new Date(Date.now() - 5 * 60 * 1000).toISOString().replace('T', ' ').slice(0, 19);
|
||||
testDb.prepare('UPDATE oauth_tokens SET revoked_at = ? WHERE refresh_token_hash = ?').run(old, hash);
|
||||
await updateRows(t, OauthTokens, { refresh_token_hash: hash }, { revoked_at: old });
|
||||
}
|
||||
|
||||
it('OAUTH-SEC-012 — replaying a rotated (old) refresh token returns invalid_grant', async () => {
|
||||
@@ -1460,7 +1462,7 @@ describe('C3 — Refresh token replay detection', () => {
|
||||
expect(t2.status).toBe(200);
|
||||
|
||||
// Replay the original (now rotated/revoked) refresh token — must be rejected
|
||||
agePastGrace(originalRefreshToken);
|
||||
await agePastGrace(originalRefreshToken);
|
||||
const t3 = await request(app).post('/oauth/token').send({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: r.client!.client_id,
|
||||
@@ -1546,7 +1548,7 @@ describe('C3 — Refresh token replay detection', () => {
|
||||
const newRefreshToken = t2.body.refresh_token;
|
||||
|
||||
// Replay original — triggers chain revocation
|
||||
agePastGrace(originalRefreshToken);
|
||||
await agePastGrace(originalRefreshToken);
|
||||
await request(app).post('/oauth/token').send({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: r.client!.client_id,
|
||||
|
||||
@@ -43,13 +43,19 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { findRow, insertRow } from '../helpers/factories/rows';
|
||||
import { InviteTokens } from '../../src/db/entities/InviteTokens.entity';
|
||||
import { WebauthnCredentials } from '../../src/db/entities/WebauthnCredentials.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: MikroORM;
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -98,10 +104,9 @@ describe('Passkey management — non-numeric id parity (Plan 3b Task 3 review, F
|
||||
// 16 instead of 404ing — verified by hand, recorded in the task report.
|
||||
it('PASSKEY-INT-003 — PATCH /auth/passkey/credentials/0x10 (hex-literal id) is refused by the toRowId narrowing, even though credential 16 exists', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare(
|
||||
`INSERT INTO webauthn_credentials (id, user_id, credential_id, public_key, name)
|
||||
VALUES (16, ?, 'cred-16', X'00', 'Original16')`,
|
||||
).run(user.id);
|
||||
await insertRow(orm, WebauthnCredentials, {
|
||||
id: 16, user: user.id, credential_id: 'cred-16', public_key: Buffer.from([0]), name: 'Original16',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.patch('/api/auth/passkey/credentials/0x10')
|
||||
@@ -110,7 +115,7 @@ describe('Passkey management — non-numeric id parity (Plan 3b Task 3 review, F
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Passkey not found' });
|
||||
const row = testDb.prepare('SELECT name FROM webauthn_credentials WHERE id = 16').get() as { name: string };
|
||||
const row = (await findRow(orm, WebauthnCredentials, { id: 16 }))!;
|
||||
expect(row.name).toBe('Original16'); // untouched — not renamed to 'Ghost'
|
||||
});
|
||||
|
||||
@@ -141,9 +146,9 @@ describe('Passkey management — non-numeric id parity (Plan 3b Task 3 review, F
|
||||
describe('Registration invite deletion — prefixed numeric literal parity (Plan 3b Task 3 review, F2)', () => {
|
||||
it('INVITE-INT-001 — DELETE /admin/invites/0x10 (hex literal) returns the legacy 404 and leaves invite id 16 untouched', async () => {
|
||||
const { user: admin } = createAdmin(testDb);
|
||||
testDb
|
||||
.prepare('INSERT INTO invite_tokens (id, token, max_uses, used_count, expires_at, created_by) VALUES (16, ?, 1, 0, NULL, ?)')
|
||||
.run('hex-literal-survivor', admin.id);
|
||||
await insertRow(orm, InviteTokens, {
|
||||
id: 16, token: 'hex-literal-survivor', max_uses: 1, used_count: 0, expires_at: null, createdByRef: admin.id,
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.delete('/api/admin/invites/0x10')
|
||||
@@ -151,6 +156,6 @@ describe('Registration invite deletion — prefixed numeric literal parity (Plan
|
||||
|
||||
expect(res.status).toBe(404);
|
||||
expect(res.body).toEqual({ error: 'Invite not found' });
|
||||
expect(testDb.prepare('SELECT id FROM invite_tokens WHERE id = 16').get()).toBeDefined();
|
||||
expect(await findRow(orm, InviteTokens, { id: 16 })).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -63,13 +63,23 @@ import { PluginMetaMigrations } from '../../../src/db/entities/PluginMetaMigrati
|
||||
import { PluginCapabilityAudit } from '../../../src/db/entities/PluginCapabilityAudit.entity';
|
||||
import { Settings } from '../../../src/db/entities/Settings.entity';
|
||||
import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity';
|
||||
import { findRow, insertRow } from '../../helpers/factories/rows';
|
||||
|
||||
let codeRoot: string;
|
||||
let dataRoot: string;
|
||||
let mod: TestingModule;
|
||||
let t: TestOrm | undefined;
|
||||
/** Seeds and reads the plugin rows; separate from the ORMs the cases hand their services. */
|
||||
let seedOrm: TestOrm;
|
||||
|
||||
beforeAll(() => {
|
||||
/** The plugin's status and last error as the supervisor left them. */
|
||||
async function pluginState(id: string): Promise<{ status: string; last_error: string | null }> {
|
||||
const plugin = (await findRow(seedOrm, Plugins, { id }))!;
|
||||
return { status: plugin.status, last_error: plugin.last_error ?? null };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
seedOrm = await createTestOrm(testDb);
|
||||
codeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-boot-code-'));
|
||||
dataRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'trekplug-boot-data-'));
|
||||
process.env.TREK_PLUGINS_DIR = codeRoot;
|
||||
@@ -87,14 +97,16 @@ beforeAll(() => {
|
||||
// The row a container recreate leaves behind: enabled, already consented to
|
||||
// db:own. Seeded 'inactive' (not the 'active' a real shutdown leaves) so the
|
||||
// poll below only terminates on a status the THIS-boot supervisor wrote.
|
||||
testDb
|
||||
.prepare("INSERT INTO plugins (id, name, status, enabled, permissions, granted_permissions, config, trek_range) VALUES ('migrator','migrator','inactive',1,'[\"db:own\"]','[\"db:own\"]','{}','>=3.0.0')")
|
||||
.run();
|
||||
await insertRow(seedOrm, Plugins, {
|
||||
id: 'migrator', name: 'migrator', status: 'inactive', enabled: 1, permissions: '["db:own"]',
|
||||
granted_permissions: '["db:own"]', config: '{}', trek_range: '>=3.0.0',
|
||||
});
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mod?.close();
|
||||
await t?.close();
|
||||
await seedOrm?.close();
|
||||
delete process.env.TREK_PLUGINS_DIR;
|
||||
delete process.env.TREK_PLUGINS_DATA_DIR;
|
||||
delete process.env.TREK_PLUGINS_ENABLED;
|
||||
@@ -172,7 +184,7 @@ describe('plugin boot vs registry scan ordering', () => {
|
||||
const runtime = mod.get(PluginRuntimeService);
|
||||
let row = { status: 'starting', last_error: null as string | null };
|
||||
for (let i = 0; i < 100; i++) {
|
||||
row = testDb.prepare("SELECT status, last_error FROM plugins WHERE id='migrator'").get() as typeof row;
|
||||
row = await pluginState('migrator');
|
||||
if (row.status === 'active' || row.status === 'error') break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
@@ -198,12 +210,11 @@ describe('plugin boot vs registry scan ordering', () => {
|
||||
const dir = path.join(codeRoot, 'addongated', 'server');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(path.join(dir, 'index.js'), `module.exports = { async onLoad(ctx) {} };`);
|
||||
testDb.prepare("INSERT INTO addons (id, name, enabled) VALUES ('needsaddon_addon', 'needsaddon_addon', 1)").run();
|
||||
testDb
|
||||
.prepare(
|
||||
"INSERT INTO plugins (id, name, status, enabled, permissions, granted_permissions, config, trek_range, dependencies) VALUES ('addongated','addongated','inactive',1,'[]','[]','{}','>=3.0.0', ?)",
|
||||
)
|
||||
.run(JSON.stringify({ requiredAddons: ['needsaddon_addon'] }));
|
||||
await insertRow(seedOrm, Addons, { id: 'needsaddon_addon', name: 'needsaddon_addon', enabled: true });
|
||||
await insertRow(seedOrm, Plugins, {
|
||||
id: 'addongated', name: 'addongated', status: 'inactive', enabled: 1, permissions: '[]', granted_permissions: '[]',
|
||||
config: '{}', trek_range: '>=3.0.0', dependencies: JSON.stringify({ requiredAddons: ['needsaddon_addon'] }),
|
||||
});
|
||||
|
||||
// Self-contained ORM/collaborators — independent of test 1's `t`, which is
|
||||
// assigned inside ITS `it` body rather than a shared beforeAll. Global
|
||||
@@ -274,7 +285,7 @@ describe('plugin boot vs registry scan ordering', () => {
|
||||
const runtime2 = mod2.get(PluginRuntimeService);
|
||||
let row = { status: 'starting', last_error: null as string | null };
|
||||
for (let i = 0; i < 100; i++) {
|
||||
row = testDb.prepare("SELECT status, last_error FROM plugins WHERE id='addongated'").get() as typeof row;
|
||||
row = await pluginState('addongated');
|
||||
if (row.status === 'active' || row.status === 'error') break;
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
}
|
||||
|
||||
@@ -28,15 +28,21 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, createTrip } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { readUser } from '../helpers/factories/users';
|
||||
import { insertRow } from '../helpers/factories/rows';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
let orm: MikroORM;
|
||||
const FIXTURE_JPEG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const FIXTURE_PDF = path.join(__dirname, '../fixtures/test.pdf');
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -96,7 +102,7 @@ describe('PUT /api/auth/me/settings (F3)', () => {
|
||||
expect(get.status).toBe(200);
|
||||
expect(get.body.user).toMatchObject({ username: 'after-name', email: 'after@example.test' });
|
||||
|
||||
const row = testDb.prepare('SELECT username, email FROM users WHERE id = ?').get(user.id) as { username: string; email: string };
|
||||
const row = await readUser(orm, user.id);
|
||||
expect(row.username).toBe('after-name');
|
||||
expect(row.email).toBe('after@example.test');
|
||||
});
|
||||
@@ -321,10 +327,7 @@ describe('Travel stats', () => {
|
||||
describe('Demo mode protections', () => {
|
||||
it('PROFILE-015 — demo user cannot upload avatar (demoUploadBlock)', async () => {
|
||||
// demoUploadBlock checks for email === 'demo@nomad.app'
|
||||
testDb.prepare(
|
||||
"INSERT INTO users (username, email, password_hash, role) VALUES ('demo', 'demo@nomad.app', 'x', 'user')"
|
||||
).run();
|
||||
const demoUser = testDb.prepare('SELECT id FROM users WHERE email = ?').get('demo@nomad.app') as { id: number };
|
||||
const demoUser = { id: await insertRow(orm, Users, { username: 'demo', email: 'demo@nomad.app', password_hash: 'x', role: 'user' }) };
|
||||
process.env.DEMO_MODE = 'true';
|
||||
|
||||
try {
|
||||
|
||||
@@ -27,6 +27,20 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment, addTripMember } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { findRow, findRows, updateRows } from '../helpers/factories/rows';
|
||||
import { BudgetItems } from '../../src/db/entities/BudgetItems.entity';
|
||||
import { DayAccommodations } from '../../src/db/entities/DayAccommodations.entity';
|
||||
import { Days } from '../../src/db/entities/Days.entity';
|
||||
import { ReservationDayPositions } from '../../src/db/entities/ReservationDayPositions.entity';
|
||||
import { Reservations } from '../../src/db/entities/Reservations.entity';
|
||||
|
||||
let orm: MikroORM;
|
||||
|
||||
/** The expense linked to the booking on the trip, or null. */
|
||||
function linkedExpense(tripId: number, reservationId: number) {
|
||||
return findRow(orm, BudgetItems, { trip: tripId, reservation: reservationId });
|
||||
}
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
@@ -34,6 +48,7 @@ let app: Application;
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
orm = nestApp.get(MikroORM);
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -260,7 +275,7 @@ describe('Update reservation', () => {
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
// Verify accommodation was updated with check-in/out
|
||||
const accom = testDb.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').get(trip.id) as any;
|
||||
const accom = (await findRow(orm, DayAccommodations, { trip: trip.id }))!;
|
||||
expect(accom.check_in).toBe('15:00');
|
||||
expect(accom.check_out).toBe('11:00');
|
||||
expect(accom.confirmation).toBe('HTL-XYZ-999');
|
||||
@@ -291,7 +306,7 @@ describe('Update reservation', () => {
|
||||
.send({ accommodation_id: hexAccId });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const row = testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(resv.id) as { accommodation_id: string | null };
|
||||
const row = (await findRow(orm, Reservations, { id: resv.id }))!;
|
||||
expect(row.accommodation_id).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -312,8 +327,8 @@ describe('H1 — a booking on a stay is restamped when the trip\'s dates change
|
||||
it('the accommodation_id RS28 stores is the legacy REAL-bound TEXT shape, and a later date change restamps the linked booking', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { start_date: '2026-12-01', end_date: '2026-12-03' });
|
||||
const day1 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2026-12-01') as { id: number };
|
||||
const day2 = testDb.prepare('SELECT id FROM days WHERE trip_id = ? AND date = ?').get(trip.id, '2026-12-02') as { id: number };
|
||||
const day1 = (await findRow(orm, Days, { trip: trip.id, date: '2026-12-01' }))!;
|
||||
const day2 = (await findRow(orm, Days, { trip: trip.id, date: '2026-12-02' }))!;
|
||||
const place = createPlace(testDb, trip.id, { name: 'Lighthouse Inn' });
|
||||
|
||||
const createRes = await request(app)
|
||||
@@ -331,7 +346,7 @@ describe('H1 — a booking on a stay is restamped when the trip\'s dates change
|
||||
|
||||
// Stored-shape assert: the legacy REAL-bound TEXT shape (`'<id>.0'`), not
|
||||
// the SQL-literal-inlined shape (`'<id>'`) `String(n)` used to store.
|
||||
const stored = testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(resvId) as { accommodation_id: string };
|
||||
const stored = (await findRow(orm, Reservations, { id: resvId }))!;
|
||||
expect(stored.accommodation_id).toMatch(/^\d+\.0$/);
|
||||
|
||||
// Move the whole trip a day later (default date_shift_mode, i.e. NOT
|
||||
@@ -344,8 +359,7 @@ describe('H1 — a booking on a stay is restamped when the trip\'s dates change
|
||||
.send({ start_date: '2026-12-02', end_date: '2026-12-04' });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const resvAfter = testDb.prepare('SELECT day_id, reservation_time FROM reservations WHERE id = ?').get(resvId) as
|
||||
{ day_id: number; reservation_time: string | null };
|
||||
const resvAfter = (await findRow(orm, Reservations, { id: resvId }))!;
|
||||
expect(resvAfter.day_id).toBe(day1.id);
|
||||
// Restamped onto day1's NEW date — red without the fix, where DY23's
|
||||
// REAL-bound compare misses a `String(n)`-shaped accommodation_id and
|
||||
@@ -453,8 +467,8 @@ describe('Delete reservation', () => {
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
const row = testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(resv.id);
|
||||
expect(row).toBeDefined();
|
||||
const row = await findRow(orm, Reservations, { id: resv.id });
|
||||
expect(row).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -498,8 +512,8 @@ describe('Batch update positions', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
|
||||
const row = testDb.prepare('SELECT * FROM reservation_day_positions WHERE reservation_id = ?').get(resv.id);
|
||||
expect(row).toBeUndefined();
|
||||
const row = await findRow(orm, ReservationDayPositions, { reservation: resv.id });
|
||||
expect(row).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -522,11 +536,9 @@ describe('Reservation budget entry integration', () => {
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const budgetItem = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, res.body.reservation.id) as any;
|
||||
expect(budgetItem).toBeDefined();
|
||||
expect(budgetItem.total_price).toBe(250);
|
||||
const budgetItem = await linkedExpense(trip.id, res.body.reservation.id);
|
||||
expect(budgetItem).not.toBeNull();
|
||||
expect(budgetItem!.total_price).toBe(250);
|
||||
expect(budgetItem.name).toBe('Flight to Paris');
|
||||
});
|
||||
|
||||
@@ -544,9 +556,7 @@ describe('Reservation budget entry integration', () => {
|
||||
});
|
||||
expect(res.status).toBe(201);
|
||||
|
||||
const budgetItems = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ?')
|
||||
.all(trip.id) as any[];
|
||||
const budgetItems = await findRows(orm, BudgetItems, { trip: trip.id });
|
||||
expect(budgetItems).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -561,11 +571,9 @@ describe('Reservation budget entry integration', () => {
|
||||
.send({ create_budget_entry: { total_price: 300, category: 'Accommodation' } });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const budgetItem = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resv.id) as any;
|
||||
expect(budgetItem).toBeDefined();
|
||||
expect(budgetItem.total_price).toBe(300);
|
||||
const budgetItem = await linkedExpense(trip.id, resv.id);
|
||||
expect(budgetItem).not.toBeNull();
|
||||
expect(budgetItem!.total_price).toBe(300);
|
||||
});
|
||||
|
||||
it('RESV-013 — PUT with create_budget_entry updates existing linked budget item', async () => {
|
||||
@@ -591,9 +599,7 @@ describe('Reservation budget entry integration', () => {
|
||||
.send({ create_budget_entry: { total_price: 150, category: 'Transport' } });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const items = testDb
|
||||
.prepare('SELECT * FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.all(trip.id, resvId) as any[];
|
||||
const items = await findRows(orm, BudgetItems, { trip: trip.id, reservation: resvId });
|
||||
expect(items).toHaveLength(1);
|
||||
expect(items[0].total_price).toBe(150);
|
||||
});
|
||||
@@ -614,10 +620,8 @@ describe('Reservation budget entry integration', () => {
|
||||
expect(createRes.status).toBe(201);
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
const before = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(before).toBeDefined();
|
||||
const before = await linkedExpense(trip.id, resvId);
|
||||
expect(before).not.toBeNull();
|
||||
|
||||
// Update WITHOUT create_budget_entry — the booking edit must NOT touch its
|
||||
// linked expense (expenses are managed from the Costs section now).
|
||||
@@ -627,10 +631,8 @@ describe('Reservation budget entry integration', () => {
|
||||
.send({ title: 'Taxi Updated' });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const after = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(after).toBeDefined();
|
||||
const after = await linkedExpense(trip.id, resvId);
|
||||
expect(after).not.toBeNull();
|
||||
});
|
||||
|
||||
it('RESV-014b — PUT with create_budget_entry total_price 0 removes the linked budget item', async () => {
|
||||
@@ -655,10 +657,8 @@ describe('Reservation budget entry integration', () => {
|
||||
.send({ title: 'Taxi', create_budget_entry: { total_price: 0 } });
|
||||
expect(updateRes.status).toBe(200);
|
||||
|
||||
const after = testDb
|
||||
.prepare('SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId);
|
||||
expect(after).toBeUndefined();
|
||||
const after = await linkedExpense(trip.id, resvId);
|
||||
expect(after).toBeNull();
|
||||
});
|
||||
|
||||
it('RESV-014c — changing the booking type updates the linked expense category', async () => {
|
||||
@@ -677,9 +677,7 @@ describe('Reservation budget entry integration', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Booking', type: 'hotel' });
|
||||
|
||||
const item = testDb
|
||||
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId) as { category: string };
|
||||
const item = (await linkedExpense(trip.id, resvId))!;
|
||||
expect(item.category).toBe('accommodation');
|
||||
});
|
||||
|
||||
@@ -694,16 +692,14 @@ describe('Reservation budget entry integration', () => {
|
||||
const resvId = createRes.body.reservation.id;
|
||||
|
||||
// Simulate a manual category pick in the Costs editor.
|
||||
testDb.prepare('UPDATE budget_items SET category = ? WHERE trip_id = ? AND reservation_id = ?').run('fees', trip.id, resvId);
|
||||
await updateRows(orm, BudgetItems, { trip: trip.id, reservation: resvId }, { category: 'fees' });
|
||||
|
||||
await request(app)
|
||||
.put(`/api/trips/${trip.id}/reservations/${resvId}`)
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ title: 'Booking', type: 'hotel' });
|
||||
|
||||
const item = testDb
|
||||
.prepare('SELECT category FROM budget_items WHERE trip_id = ? AND reservation_id = ?')
|
||||
.get(trip.id, resvId) as { category: string };
|
||||
const item = (await linkedExpense(trip.id, resvId))!;
|
||||
expect(item.category).toBe('fees');
|
||||
});
|
||||
});
|
||||
@@ -734,10 +730,8 @@ describe('Reservation accommodation delete', () => {
|
||||
const reservationId = createRes.body.reservation.id;
|
||||
|
||||
// Verify accommodation was created
|
||||
const accom = testDb.prepare(
|
||||
'SELECT id FROM day_accommodations WHERE trip_id = ?'
|
||||
).get(trip.id) as any;
|
||||
expect(accom).toBeDefined();
|
||||
const accom = (await findRow(orm, DayAccommodations, { trip: trip.id }))!;
|
||||
expect(accom).not.toBeNull();
|
||||
|
||||
// Delete reservation — should also remove the accommodation
|
||||
const delRes = await request(app)
|
||||
@@ -745,10 +739,8 @@ describe('Reservation accommodation delete', () => {
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const accomAfter = testDb.prepare(
|
||||
'SELECT id FROM day_accommodations WHERE id = ?'
|
||||
).get(accom.id);
|
||||
expect(accomAfter).toBeUndefined();
|
||||
const accomAfter = await findRow(orm, DayAccommodations, { id: accom.id });
|
||||
expect(accomAfter).toBeNull();
|
||||
});
|
||||
|
||||
it('RESV-009b — DELETE reservation linked to accommodation also removes its linked budget item (issue #933)', async () => {
|
||||
@@ -771,10 +763,8 @@ describe('Reservation accommodation delete', () => {
|
||||
expect(createRes.status).toBe(201);
|
||||
const reservationId = createRes.body.reservation.id;
|
||||
|
||||
const budgetBefore = testDb.prepare(
|
||||
'SELECT id FROM budget_items WHERE trip_id = ? AND reservation_id = ?'
|
||||
).get(trip.id, reservationId);
|
||||
expect(budgetBefore).toBeDefined();
|
||||
const budgetBefore = await linkedExpense(trip.id, reservationId);
|
||||
expect(budgetBefore).not.toBeNull();
|
||||
|
||||
// Delete via the reservation endpoint
|
||||
const delRes = await request(app)
|
||||
@@ -782,9 +772,7 @@ describe('Reservation accommodation delete', () => {
|
||||
.set('Cookie', authCookie(user.id));
|
||||
expect(delRes.status).toBe(200);
|
||||
|
||||
const budgetAfter = testDb.prepare(
|
||||
'SELECT id FROM budget_items WHERE trip_id = ?'
|
||||
).get(trip.id);
|
||||
expect(budgetAfter).toBeUndefined();
|
||||
const budgetAfter = await findRow(orm, BudgetItems, { trip: trip.id });
|
||||
expect(budgetAfter).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,6 +31,9 @@ import { StorageEventsService } from '../../src/nest/storage/storage-events.serv
|
||||
import { BACKENDS_KEY, CATEGORIES_KEY, StorageRegistryService } from '../../src/nest/storage/storage-registry.service';
|
||||
import { StorageService } from '../../src/nest/storage/storage.service';
|
||||
import { createTestUnitOfWork, createTestAppSettingsRepo, sharedTestOrm } from '../helpers/test-uow';
|
||||
import { deleteRows } from '../helpers/factories/rows';
|
||||
import { setAppSetting } from '../helpers/factories/settings';
|
||||
import { AppSettings } from '../../src/db/entities/AppSettings.entity';
|
||||
|
||||
const testDb = createSnapshotTestDb();
|
||||
|
||||
@@ -45,15 +48,13 @@ function envStub(): { placePhotoDir: string | undefined } {
|
||||
return { placePhotoDir: undefined };
|
||||
}
|
||||
|
||||
function setSetting(key: string, value: unknown): void {
|
||||
testDb
|
||||
.prepare('INSERT OR REPLACE INTO app_settings (key, value) VALUES (?, ?)')
|
||||
.run(key, JSON.stringify(value));
|
||||
async function setSetting(key: string, value: unknown): Promise<void> {
|
||||
await setAppSetting(await sharedTestOrm(testDb), key, JSON.stringify(value));
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
while (tmpDirs.length) fs.rmSync(tmpDirs.pop()!, { recursive: true, force: true });
|
||||
testDb.prepare("DELETE FROM app_settings WHERE key LIKE 'storage.%'").run();
|
||||
await deleteRows(await sharedTestOrm(testDb), AppSettings, { key: { $like: 'storage.%' } });
|
||||
});
|
||||
|
||||
describe('C6 — restore reloads the storage registry (audit #4)', () => {
|
||||
@@ -62,11 +63,11 @@ describe('C6 — restore reloads the storage registry (audit #4)', () => {
|
||||
const rootB = makeTmpDir();
|
||||
|
||||
// Pre-restore world: 'files' resolves to backend nas-a.
|
||||
setSetting(BACKENDS_KEY, [
|
||||
await setSetting(BACKENDS_KEY, [
|
||||
{ name: 'nas-a', type: 'local', options: { root: rootA } },
|
||||
{ name: 'nas-b', type: 'local', options: { root: rootB } },
|
||||
]);
|
||||
setSetting(CATEGORIES_KEY, { files: 'nas-a' });
|
||||
await setSetting(CATEGORIES_KEY, { files: 'nas-a' });
|
||||
|
||||
const registry = new StorageRegistryService(
|
||||
await createTestAppSettingsRepo(testDb),
|
||||
@@ -90,7 +91,7 @@ describe('C6 — restore reloads the storage registry (audit #4)', () => {
|
||||
// production: the restored archive's travel.db is now live, and IT names a
|
||||
// different backend for 'files' (an admin on the source install pointed
|
||||
// 'files' at a different backend before taking that backup).
|
||||
setSetting(CATEGORIES_KEY, { files: 'nas-b' });
|
||||
await setSetting(CATEGORIES_KEY, { files: 'nas-b' });
|
||||
|
||||
// Audit #4, pre-fix: the registry is still holding the pre-restore driver
|
||||
// map at this point — resolve() is stale until something calls reload().
|
||||
|
||||
Reference in New Issue
Block a user