mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-24 16:03:02 +00:00
test(server): seed the service, MCP and route specs through the ORM factories
Service, MCP, integration and e2e suites with up to a dozen raw statements now seed and read through the factories. A buildApp() suite uses the app's own ORM, a partial-module e2e or a unit suite a createTestOrm() or sharedTestOrm() handle on its database. Reads that used to come back undefined now come back null, and the assertions say so; where a projected SELECT was compared whole, the same columns are picked off the row. Two reads inside the scheduler's delivery callback stay synchronous raw statements, marked, so they keep seeing the row at the moment of delivery. Two more database mocks lose a canAccessTrip/isOwner stand-in the module no longer exports.
This commit is contained in:
@@ -6,6 +6,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
@@ -27,10 +28,16 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { makePlace } from '../helpers/factories/places';
|
||||
import { findRow } from '../helpers/factories/rows';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { PlaceRegions } from '../../src/db/entities/PlaceRegions.entity';
|
||||
import { getRegionGeo } from '../../src/nest/atlas/atlas-geo';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
/** The app's own ORM, which the factories seed and read through. */
|
||||
const orm = (): FactoryOrm => nestApp.get(MikroORM);
|
||||
|
||||
beforeAll(async () => {
|
||||
// Stub the admin-1 GeoJSON download so /regions/geo is deterministic and never
|
||||
@@ -93,9 +100,10 @@ describe('Atlas stats', () => {
|
||||
const iso = (days: number) => new Date(Date.now() + days * 86_400_000).toISOString().slice(0, 10);
|
||||
const past = createTrip(testDb, user.id, { title: 'Rome, last month', start_date: iso(-40), end_date: iso(-30) });
|
||||
const future = createTrip(testDb, user.id, { title: 'Tokyo, next month', start_date: iso(30), end_date: iso(40) });
|
||||
const insertPlace = testDb.prepare('INSERT INTO places (trip_id, name, address) VALUES (?, ?, ?)');
|
||||
insertPlace.run(past.id, 'Colosseum', 'Piazza del Colosseo, Rome, Italy');
|
||||
insertPlace.run(future.id, 'Senso-ji', 'Asakusa, Tokyo, Japan');
|
||||
// No coordinates and no category: the country has to come from the address.
|
||||
const unplaced = { lat: null, lng: null, category: null };
|
||||
await makePlace(orm(), past.id, { ...unplaced, name: 'Colosseum', address: 'Piazza del Colosseo, Rome, Italy' });
|
||||
await makePlace(orm(), future.id, { ...unplaced, name: 'Senso-ji', address: 'Asakusa, Tokyo, Japan' });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/addons/atlas/stats')
|
||||
@@ -516,11 +524,14 @@ describe('Regions geo', () => {
|
||||
describe('A place that moves takes its Atlas country with it (#2527)', () => {
|
||||
// The region cache is filled by a background task that GET /regions only starts,
|
||||
// so wait for the row the way the next Atlas load would find it.
|
||||
async function readRegion(placeId: number): Promise<{ country_code: string; region_code: string } | undefined> {
|
||||
const row = await findRow(orm(), PlaceRegions, { place: placeId });
|
||||
return row ? { country_code: row.country_code, region_code: row.region_code } : undefined;
|
||||
}
|
||||
|
||||
async function regionRowOf(placeId: number): Promise<{ country_code: string; region_code: string } | undefined> {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const row = testDb.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(placeId) as
|
||||
| { country_code: string; region_code: string }
|
||||
| undefined;
|
||||
const row = await readRegion(placeId);
|
||||
if (row) return row;
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
}
|
||||
@@ -536,9 +547,13 @@ describe('A place that moves takes its Atlas country with it (#2527)', () => {
|
||||
it('ATLAS-015: correcting a place from France to Germany moves it on the Atlas', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Wrongly imported', start_date: '2025-05-01', end_date: '2025-05-05' });
|
||||
const place = testDb
|
||||
.prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?) RETURNING id')
|
||||
.get(trip.id, 'Hotel', 48.8566, 2.3522, 'Rue de Rivoli, Paris, France') as { id: number };
|
||||
const place = await makePlace(orm(), trip.id, {
|
||||
name: 'Hotel',
|
||||
lat: 48.8566,
|
||||
lng: 2.3522,
|
||||
address: 'Rue de Rivoli, Paris, France',
|
||||
category: null,
|
||||
});
|
||||
|
||||
// The place is seen on the Atlas once, which caches France for it.
|
||||
await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
@@ -565,9 +580,13 @@ describe('A place that moves takes its Atlas country with it (#2527)', () => {
|
||||
it('ATLAS-015: an edit that leaves the location alone keeps the cached country', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id, { title: 'Paris', start_date: '2025-05-01', end_date: '2025-05-05' });
|
||||
const place = testDb
|
||||
.prepare('INSERT INTO places (trip_id, name, lat, lng, address) VALUES (?, ?, ?, ?, ?) RETURNING id')
|
||||
.get(trip.id, 'Louvre', 48.8606, 2.3376, 'Rue de Rivoli, Paris, France') as { id: number };
|
||||
const place = await makePlace(orm(), trip.id, {
|
||||
name: 'Louvre',
|
||||
lat: 48.8606,
|
||||
lng: 2.3376,
|
||||
address: 'Rue de Rivoli, Paris, France',
|
||||
category: null,
|
||||
});
|
||||
await request(app).get('/api/addons/atlas/regions').set('Cookie', authCookie(user.id));
|
||||
const cached = await regionRowOf(place.id);
|
||||
expect(cached?.country_code).toBe('FR');
|
||||
@@ -579,6 +598,6 @@ describe('A place that moves takes its Atlas country with it (#2527)', () => {
|
||||
.send({ notes: 'Closed on Tuesdays', lat: 48.8606, lng: 2.3376, address: 'Rue de Rivoli, Paris, France' });
|
||||
expect(put.status).toBe(200);
|
||||
|
||||
expect(testDb.prepare('SELECT country_code, region_code FROM place_regions WHERE place_id = ?').get(place.id)).toEqual(cached);
|
||||
expect(await readRegion(place.id)).toEqual(cached);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
// ── Hoisted DB mock ──────────────────────────────────────────────────────────
|
||||
|
||||
@@ -41,6 +42,11 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits, setAddonEnabled } from '../helpers/test-db';
|
||||
import { createUser, createTrip, addTripMember, addTripPhoto, addAlbumLink } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { findRow, findRows, updateRows } from '../helpers/factories/rows';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { PhotoProviders } from '../../src/db/entities/PhotoProviders.entity';
|
||||
import { TrekPhotos } from '../../src/db/entities/TrekPhotos.entity';
|
||||
import { TripPhotos } from '../../src/db/entities/TripPhotos.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
@@ -52,6 +58,22 @@ beforeAll(async () => {
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
});
|
||||
|
||||
/** The app's own ORM, which the factories seed and read through. */
|
||||
const orm = (): FactoryOrm => nestApp.get(MikroORM);
|
||||
|
||||
/** The trip_photos row pointing at the registered photo `assetId`, on `tripId` when given. */
|
||||
async function tripPhotoOf(assetId: string, tripId?: number) {
|
||||
const photoIds = (await findRows(orm(), TrekPhotos, { asset_id: assetId })).map((p) => p.id);
|
||||
return findRow(orm(), TripPhotos, { photo: { $in: photoIds }, ...(tripId !== undefined ? { trip: tripId } : {}) });
|
||||
}
|
||||
|
||||
/** The trip_photos row for `assetId` on `tripId`; fails the case when there is none. */
|
||||
async function requireTripPhoto(assetId: string, tripId: number) {
|
||||
const row = await tripPhotoOf(assetId, tripId);
|
||||
if (!row) throw new Error(`no trip photo ${assetId} on trip ${tripId}`);
|
||||
return row;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
await resetRateLimits(nestApp);
|
||||
@@ -61,7 +83,7 @@ beforeEach(async () => {
|
||||
// configure it before it's usable in production); the legacy test helper always
|
||||
// seeded it enabled, which is what these tests assume. Same convention
|
||||
// memories-synology.test.ts already uses for its own provider.
|
||||
testDb.prepare("UPDATE photo_providers SET enabled = 1 WHERE id = 'immich'").run();
|
||||
await updateRows(orm(), PhotoProviders, { id: 'immich' }, { enabled: 1 });
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -143,12 +165,9 @@ describe('Unified photo management', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.added).toBe(2);
|
||||
|
||||
const rows = testDb.prepare(`
|
||||
SELECT tkp.asset_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ?
|
||||
`).all(trip.id) as any[];
|
||||
expect(rows.map((r: any) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b']));
|
||||
const photoIds = (await findRows(orm(), TripPhotos, { trip: trip.id })).map((r) => r.photo_id);
|
||||
const rows = await findRows(orm(), TrekPhotos, { id: { $in: photoIds } });
|
||||
expect(rows.map((r) => r.asset_id)).toEqual(expect.arrayContaining(['asset-a', 'asset-b']));
|
||||
});
|
||||
|
||||
it('UNIFIED-005 — POST photos with empty selections returns 400', async () => {
|
||||
@@ -179,11 +198,7 @@ describe('Unified photo management', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-tog', 'immich', { shared: false });
|
||||
const trekRef = testDb.prepare(`
|
||||
SELECT tp.photo_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tkp.asset_id = ?
|
||||
`).get(trip.id, 'asset-tog') as any;
|
||||
const trekRef = await requireTripPhoto('asset-tog', trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.put(`${photosUrl(trip.id)}/sharing`)
|
||||
@@ -191,12 +206,8 @@ describe('Unified photo management', () => {
|
||||
.send({ photo_id: trekRef.photo_id, shared: true });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = testDb.prepare(`
|
||||
SELECT tp.shared FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tkp.asset_id = ?
|
||||
`).get('asset-tog') as any;
|
||||
expect(row.shared).toBe(1);
|
||||
const row = await tripPhotoOf('asset-tog');
|
||||
expect(row?.shared).toBe(1);
|
||||
});
|
||||
|
||||
it('UNIFIED-008 — PUT photos/sharing on non-member trip returns 404', async () => {
|
||||
@@ -216,11 +227,7 @@ describe('Unified photo management', () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-del', 'immich');
|
||||
const trekRef = testDb.prepare(`
|
||||
SELECT tp.photo_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tkp.asset_id = ?
|
||||
`).get(trip.id, 'asset-del') as any;
|
||||
const trekRef = await requireTripPhoto('asset-del', trip.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(photosUrl(trip.id))
|
||||
@@ -228,23 +235,14 @@ describe('Unified photo management', () => {
|
||||
.send({ photo_id: trekRef.photo_id });
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
const row = testDb.prepare(`
|
||||
SELECT tp.* FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tkp.asset_id = ?
|
||||
`).get('asset-del');
|
||||
expect(row).toBeUndefined();
|
||||
expect(await tripPhotoOf('asset-del')).toBeNull();
|
||||
});
|
||||
|
||||
it('UNIFIED-009a — DELETE photos is held to the body contract, and still takes a numeric string', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const trip = createTrip(testDb, user.id);
|
||||
addTripPhoto(testDb, trip.id, user.id, 'asset-contract', 'immich');
|
||||
const trekRef = testDb.prepare(`
|
||||
SELECT tp.photo_id FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tp.trip_id = ? AND tkp.asset_id = ?
|
||||
`).get(trip.id, 'asset-contract') as any;
|
||||
const trekRef = await requireTripPhoto('asset-contract', trip.id);
|
||||
|
||||
// A DELETE that reads a body validates it like any other write, so a
|
||||
// photo_id that is neither a number nor a string never reaches the handler.
|
||||
@@ -264,12 +262,7 @@ describe('Unified photo management', () => {
|
||||
.send({ photo_id: String(trekRef.photo_id) });
|
||||
|
||||
expect(ok.status).toBe(200);
|
||||
const row = testDb.prepare(`
|
||||
SELECT tp.* FROM trip_photos tp
|
||||
JOIN trek_photos tkp ON tkp.id = tp.photo_id
|
||||
WHERE tkp.asset_id = ?
|
||||
`).get('asset-contract');
|
||||
expect(row).toBeUndefined();
|
||||
expect(await tripPhotoOf('asset-contract')).toBeNull();
|
||||
});
|
||||
|
||||
it('UNIFIED-010 — DELETE photos on non-member trip returns 404', async () => {
|
||||
@@ -336,14 +329,14 @@ describe('Unified album-link management', () => {
|
||||
addAlbumLink(testDb, trip.id, user.id, 'immich', 'album-enabled');
|
||||
|
||||
// Disable the immich provider
|
||||
testDb.prepare('UPDATE photo_providers SET enabled = 0 WHERE id = ?').run('immich');
|
||||
await updateRows(orm(), PhotoProviders, { id: 'immich' }, { enabled: 0 });
|
||||
|
||||
const res = await request(app)
|
||||
.get(albumLinksUrl(trip.id))
|
||||
.set('Cookie', authCookie(user.id));
|
||||
|
||||
// Re-enable for future tests
|
||||
testDb.prepare('UPDATE photo_providers SET enabled = 1 WHERE id = ?').run('immich');
|
||||
await updateRows(orm(), PhotoProviders, { id: 'immich' }, { enabled: 1 });
|
||||
|
||||
expect(res.status).toBe(400); // no providers enabled → error
|
||||
});
|
||||
|
||||
@@ -9,6 +9,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
vi.mock('../../src/db/database', async () => {
|
||||
const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock');
|
||||
@@ -43,9 +44,17 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createAdmin, disableNotificationPref } from '../helpers/factories';
|
||||
import { authCookie } from '../helpers/auth';
|
||||
import { findRow, updateRows } from '../helpers/factories/rows';
|
||||
import { makeNotification } from '../helpers/factories/notifications';
|
||||
import { setUserSetting } from '../helpers/factories/settings';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { NotificationChannelPreferences } from '../../src/db/entities/NotificationChannelPreferences.entity';
|
||||
import { Notifications } from '../../src/db/entities/Notifications.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
/** The app's own ORM, which the factories seed and read through. */
|
||||
const orm = (): FactoryOrm => nestApp.get(MikroORM);
|
||||
|
||||
beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
@@ -249,10 +258,8 @@ describe('PUT /api/notifications/preferences — matrix format', () => {
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.preferences['trip_invite']['email']).toBe(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');
|
||||
expect(row).toBeUndefined();
|
||||
const row = await findRow(orm(), NotificationChannelPreferences, { user: user.id, event_type: 'trip_invite', channel: 'email' });
|
||||
expect(row).toBeNull();
|
||||
});
|
||||
|
||||
it('NROUTE-009 — partial update does not affect other preferences', async () => {
|
||||
@@ -375,7 +382,7 @@ describe('Notification test endpoints', () => {
|
||||
|
||||
it('NOTIF-009 — POST /api/notifications/test-ntfy falls back to user saved topic', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
testDb.prepare("INSERT OR REPLACE INTO settings (user_id, key, value) VALUES (?, 'ntfy_topic', 'saved-user-topic')").run(user.id);
|
||||
await setUserSetting(orm(), user.id, 'ntfy_topic', 'saved-user-topic');
|
||||
|
||||
const res = await request(app)
|
||||
.post('/api/notifications/test-ntfy')
|
||||
@@ -391,28 +398,34 @@ describe('Notification test endpoints', () => {
|
||||
// Helper: insert a boolean notification directly into the DB
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
function insertBooleanNotification(recipientId: number): number {
|
||||
const result = testDb.prepare(`
|
||||
INSERT INTO notifications (
|
||||
type, scope, target, sender_id, recipient_id,
|
||||
title_key, title_params, text_key, text_params,
|
||||
positive_text_key, negative_text_key, positive_callback, negative_callback
|
||||
) VALUES ('boolean', 'user', ?, NULL, ?, 'notif.test.title', '{}', 'notif.test.text', '{}',
|
||||
'notif.action.accept', 'notif.action.decline',
|
||||
'{"action":"test_approve","payload":{}}', '{"action":"test_deny","payload":{}}'
|
||||
)
|
||||
`).run(recipientId, recipientId);
|
||||
return result.lastInsertRowid as number;
|
||||
async function insertBooleanNotification(recipientId: number): Promise<number> {
|
||||
const row = await makeNotification(orm(), recipientId, {
|
||||
type: 'boolean',
|
||||
target: recipientId,
|
||||
sender: null,
|
||||
title_key: 'notif.test.title',
|
||||
title_params: '{}',
|
||||
text_key: 'notif.test.text',
|
||||
text_params: '{}',
|
||||
positive_text_key: 'notif.action.accept',
|
||||
negative_text_key: 'notif.action.decline',
|
||||
positive_callback: '{"action":"test_approve","payload":{}}',
|
||||
negative_callback: '{"action":"test_deny","payload":{}}',
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
function insertSimpleNotification(recipientId: number): number {
|
||||
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);
|
||||
return result.lastInsertRowid as number;
|
||||
async function insertSimpleNotification(recipientId: number): Promise<number> {
|
||||
const row = await makeNotification(orm(), recipientId, {
|
||||
type: 'simple',
|
||||
target: recipientId,
|
||||
sender: null,
|
||||
title_key: 'notif.test.title',
|
||||
title_params: '{}',
|
||||
text_key: 'notif.test.text',
|
||||
text_params: '{}',
|
||||
});
|
||||
return row.id;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -422,7 +435,7 @@ function insertSimpleNotification(recipientId: number): number {
|
||||
describe('POST /api/notifications/in-app/:id/respond', () => {
|
||||
it('NROUTE-011 — valid positive response returns success and updated notification', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
const id = await insertBooleanNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
@@ -437,7 +450,7 @@ describe('POST /api/notifications/in-app/:id/respond', () => {
|
||||
|
||||
it('NROUTE-012 — invalid response value returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
const id = await insertBooleanNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
@@ -460,7 +473,7 @@ describe('POST /api/notifications/in-app/:id/respond', () => {
|
||||
|
||||
it('NROUTE-014 — double response returns 400', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertBooleanNotification(user.id);
|
||||
const id = await insertBooleanNotification(user.id);
|
||||
|
||||
await request(app)
|
||||
.post(`/api/notifications/in-app/${id}/respond`)
|
||||
@@ -519,8 +532,8 @@ describe('PUT /api/admin/notification-preferences', () => {
|
||||
describe('In-app notifications — CRUD with data', () => {
|
||||
it('NROUTE-017 — GET /in-app returns created notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
await insertSimpleNotification(user.id);
|
||||
await insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app')
|
||||
@@ -534,8 +547,8 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
|
||||
it('NROUTE-018 — unread count reflects actual unread notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
await insertSimpleNotification(user.id);
|
||||
await insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app/unread-count')
|
||||
@@ -547,7 +560,7 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
|
||||
it('NROUTE-019 — mark-read on existing notification succeeds and decrements unread count', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
const id = await insertSimpleNotification(user.id);
|
||||
|
||||
const markRes = await request(app)
|
||||
.put(`/api/notifications/in-app/${id}/read`)
|
||||
@@ -563,9 +576,9 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
|
||||
it('NROUTE-020 — mark-unread on a read notification succeeds', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
const id = await insertSimpleNotification(user.id);
|
||||
// Mark read first
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id);
|
||||
await updateRows(orm(), Notifications, { id }, { is_read: 1 });
|
||||
|
||||
const res = await request(app)
|
||||
.put(`/api/notifications/in-app/${id}/unread`)
|
||||
@@ -573,13 +586,13 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(id) as { is_read: number };
|
||||
expect(row.is_read).toBe(0);
|
||||
const row = await findRow(orm(), Notifications, { id });
|
||||
expect(row?.is_read).toBe(0);
|
||||
});
|
||||
|
||||
it('NROUTE-021 — DELETE on existing notification removes it', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id = insertSimpleNotification(user.id);
|
||||
const id = await insertSimpleNotification(user.id);
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/notifications/in-app/${id}`)
|
||||
@@ -587,16 +600,15 @@ describe('In-app notifications — CRUD with data', () => {
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.success).toBe(true);
|
||||
const row = testDb.prepare('SELECT id FROM notifications WHERE id = ?').get(id);
|
||||
expect(row).toBeUndefined();
|
||||
expect(await findRow(orm(), Notifications, { id })).toBeNull();
|
||||
});
|
||||
|
||||
it('NROUTE-022 — unread_only=true filter returns only unread notifications', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const id1 = insertSimpleNotification(user.id);
|
||||
insertSimpleNotification(user.id);
|
||||
const id1 = await insertSimpleNotification(user.id);
|
||||
await insertSimpleNotification(user.id);
|
||||
// Mark first one read
|
||||
testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(id1);
|
||||
await updateRows(orm(), Notifications, { id: id1 }, { is_read: 1 });
|
||||
|
||||
const res = await request(app)
|
||||
.get('/api/notifications/in-app?unread_only=true')
|
||||
|
||||
@@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach }
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
|
||||
// ── DB mock (async vi.mock factory over the migrated schema snapshot) ────────
|
||||
|
||||
@@ -37,6 +38,12 @@ import type { MockInstance } from 'vitest';
|
||||
import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser } from '../helpers/factories';
|
||||
import { findRow, findRows } from '../helpers/factories/rows';
|
||||
import { readUser } from '../helpers/factories/users';
|
||||
import { setAppSetting } from '../helpers/factories/settings';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../src/db/entities/Users.entity';
|
||||
import { OidcService } from '../../src/nest/oidc/oidc.service';
|
||||
|
||||
/** Read one cookie's value out of a response's Set-Cookie header, as a browser would. */
|
||||
@@ -57,6 +64,8 @@ const MOCK_DISCOVERY_DOC = {
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
/** The app's own ORM, which the factories seed and read through. */
|
||||
const orm = (): FactoryOrm => nestApp.get(MikroORM);
|
||||
let oidcSvc: OidcService;
|
||||
let mockDiscover: MockInstance<OidcService['discover']>;
|
||||
let mockExchangeCode: MockInstance<OidcService['exchangeCodeForToken']>;
|
||||
@@ -183,13 +192,11 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
|
||||
expect(res.status).toBe(302);
|
||||
// The query from the report, against the same table.
|
||||
const rows = testDb.prepare('SELECT user_id, action, details FROM audit_log WHERE user_id = ? ORDER BY id DESC').all(user.id) as
|
||||
{ user_id: number; action: string; details: string | null }[];
|
||||
const rows = await findRows(orm(), AuditLog, { user: user.id }, { id: 'desc' });
|
||||
expect(rows.map(r => r.action)).toContain('user.login');
|
||||
const login = rows.find(r => r.action === 'user.login')!;
|
||||
expect(JSON.parse(login.details || '{}')).toEqual({ method: 'oidc' });
|
||||
const counted = testDb.prepare('SELECT login_count FROM users WHERE id = ?').get(user.id) as { login_count: number };
|
||||
expect(counted.login_count).toBe(1);
|
||||
expect((await readUser(orm(), user.id)).login_count).toBe(1);
|
||||
});
|
||||
|
||||
it('OIDC-005: new user gets created when registration is open', async () => {
|
||||
@@ -210,12 +217,11 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
expect(res.headers.location).toContain('/login?oidc_code=');
|
||||
|
||||
// Verify user was created in DB
|
||||
const newUser = testDb.prepare("SELECT * FROM users WHERE email = 'newuser@example.com'").get() as { id: number; username: string; role: string } | undefined;
|
||||
expect(newUser).toBeDefined();
|
||||
const newUser = await findRow(orm(), Users, { email: 'newuser@example.com' });
|
||||
expect(newUser).not.toBeNull();
|
||||
// Registered, the way the audit log reports a password signup, and then logged in:
|
||||
// an admin reading user.register for who got an account sees the SSO ones too.
|
||||
const rows = testDb.prepare('SELECT action, details FROM audit_log WHERE user_id = ? ORDER BY id').all(newUser!.id) as
|
||||
{ action: string; details: string | null }[];
|
||||
const rows = await findRows(orm(), AuditLog, { user: newUser!.id }, { id: 'asc' });
|
||||
expect(rows.map(r => r.action)).toEqual(['user.register', 'user.login']);
|
||||
expect(JSON.parse(rows[0].details || '{}')).toEqual({ username: newUser!.username, email: 'newuser@example.com', role: newUser!.role, method: 'oidc' });
|
||||
});
|
||||
@@ -300,7 +306,7 @@ describe('GET /api/auth/oidc/callback', () => {
|
||||
// Need at least one existing user so isFirstUser=false
|
||||
createUser(testDb, { email: 'existing@example.com' });
|
||||
// Disable registration
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allow_registration', 'false')").run();
|
||||
await setAppSetting(orm(), 'allow_registration', 'false');
|
||||
|
||||
mockDiscover.mockResolvedValueOnce(MOCK_DISCOVERY_DOC);
|
||||
mockExchangeCode.mockResolvedValueOnce({ access_token: 'tok', id_token: 'fake.id.token', _ok: true, _status: 200 });
|
||||
|
||||
@@ -97,6 +97,7 @@ import { PluginUserSettingsService } from '../../../src/nest/plugins/plugin-user
|
||||
import { createTestAddonsService } from '../../helpers/test-addons';
|
||||
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
||||
import { createTestOrm, type TestOrm } from '../../helpers/test-orm';
|
||||
import { findRow, insertRow } from '../../helpers/factories/rows';
|
||||
import { AuditLog } from '../../../src/db/entities/AuditLog.entity';
|
||||
import { Users } from '../../../src/db/entities/Users.entity';
|
||||
import { Plugins } from '../../../src/db/entities/Plugins.entity';
|
||||
@@ -297,12 +298,10 @@ describe('PluginRuntimeService scheduler sweep — registered through CronRegist
|
||||
// disk — a real installed plugin always has (at least) a `plugins` row, so this
|
||||
// fixture needs one too, or the erasure row would be reaped before ever
|
||||
// reaching the ACK-gated delivery path this test is actually proving.
|
||||
testDb.prepare("INSERT INTO plugins (id, name) VALUES (?, ?)").run(pluginId, pluginId);
|
||||
testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?,?,?,?,?)')
|
||||
.run(pluginId, 'oneshot', oneShotSeedDueAt, 'null', null);
|
||||
testDb.prepare('INSERT INTO plugin_scheduled_tasks (plugin_id, name, due_at, payload, every_ms) VALUES (?,?,?,?,?)')
|
||||
.run(pluginId, 'recurring', recurringSeedDueAt, 'null', 60_000);
|
||||
testDb.prepare('INSERT INTO plugin_user_erasure_queue (plugin_id, user_id) VALUES (?,?)').run(pluginId, 4242);
|
||||
await insertRow(t, Plugins, { id: pluginId, name: pluginId });
|
||||
await insertRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'oneshot', due_at: oneShotSeedDueAt, payload: 'null', every_ms: null });
|
||||
await insertRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'recurring', due_at: recurringSeedDueAt, payload: 'null', every_ms: 60_000 });
|
||||
await insertRow(t, PluginUserErasureQueue, { plugin_id: pluginId, user_id: 4242 });
|
||||
|
||||
// Mark the plugin active without a real child spawn — the same technique
|
||||
// tests/unit/plugins/supervisor-lifecycle.test.ts already uses to drive
|
||||
@@ -321,9 +320,11 @@ describe('PluginRuntimeService scheduler sweep — registered through CronRegist
|
||||
// the row's disposition write must already have landed by the moment
|
||||
// delivery is attempted, since a crash here must never double-fire.
|
||||
if (name === 'oneshot') {
|
||||
// test-sql-allow: read synchronously inside the delivery call, so it sees the row at that very moment.
|
||||
const row = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(id, 'oneshot');
|
||||
expect(row).toBeUndefined(); // already deleted before delivery
|
||||
} else if (name === 'recurring') {
|
||||
// test-sql-allow: read synchronously inside the delivery call, so it sees the row at that very moment.
|
||||
const row = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(id, 'recurring') as { due_at: number } | undefined;
|
||||
expect(row).toBeDefined();
|
||||
expect(row!.due_at).toBeGreaterThan(recurringSeedDueAt); // already re-armed before delivery
|
||||
@@ -341,17 +342,17 @@ describe('PluginRuntimeService scheduler sweep — registered through CronRegist
|
||||
.toEqual(['oneshot', 'recurring']);
|
||||
expect(invokeCalls).toContainEqual({ method: 'invoke.deleteUserData', payload: { userId: 4242 } });
|
||||
|
||||
const oneShotRow = testDb.prepare('SELECT id FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'oneshot');
|
||||
expect(oneShotRow).toBeUndefined();
|
||||
const recurringRow = testDb.prepare('SELECT due_at FROM plugin_scheduled_tasks WHERE plugin_id=? AND name=?').get(pluginId, 'recurring') as { due_at: number } | undefined;
|
||||
expect(recurringRow).toBeDefined();
|
||||
const oneShotRow = await findRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'oneshot' });
|
||||
expect(oneShotRow).toBeNull();
|
||||
const recurringRow = await findRow(t, PluginScheduledTasks, { plugin_id: pluginId, name: 'recurring' });
|
||||
expect(recurringRow).not.toBeNull();
|
||||
// Re-armed to Date.now() (read fresh, at fire time — not the seed timestamp)
|
||||
// + every_ms: bounded rather than exact, since real wall-clock time elapses
|
||||
// between seeding the row and the tick firing it.
|
||||
expect(recurringRow!.due_at).toBeGreaterThanOrEqual(now + 60_000);
|
||||
expect(recurringRow!.due_at).toBeLessThan(now + 60_000 + 30_000);
|
||||
const erasureRow = testDb.prepare('SELECT id FROM plugin_user_erasure_queue WHERE plugin_id=? AND user_id=?').get(pluginId, 4242);
|
||||
expect(erasureRow).toBeUndefined(); // drained on ACK
|
||||
const erasureRow = await findRow(t, PluginUserErasureQueue, { plugin_id: pluginId, user_id: 4242 });
|
||||
expect(erasureRow).toBeNull(); // drained on ACK
|
||||
} finally {
|
||||
invokeSpy.mockRestore();
|
||||
supervisor.running.delete(pluginId);
|
||||
|
||||
@@ -11,6 +11,7 @@ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vites
|
||||
import request from 'supertest';
|
||||
import type { Application } from 'express';
|
||||
import type { INestApplication } from '@nestjs/common';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
|
||||
@@ -34,9 +35,17 @@ import { buildApp } from '../../src/bootstrap';
|
||||
import { resetTestDb, resetRateLimits } from '../helpers/test-db';
|
||||
import { createUser, createTrip } from '../helpers/factories';
|
||||
import { authCookie, authHeader, generateToken } from '../helpers/auth';
|
||||
import { findRows, updateRows } from '../helpers/factories/rows';
|
||||
import { readUser } from '../helpers/factories/users';
|
||||
import { setAppSetting } from '../helpers/factories/settings';
|
||||
import type { FactoryOrm } from '../helpers/factories/context';
|
||||
import { AuditLog } from '../../src/db/entities/AuditLog.entity';
|
||||
import { TripFiles } from '../../src/db/entities/TripFiles.entity';
|
||||
|
||||
let nestApp: INestApplication;
|
||||
let app: Application;
|
||||
/** The app's own ORM, which the factories seed and read through. */
|
||||
const orm = (): FactoryOrm => nestApp.get(MikroORM);
|
||||
const FIXTURE_IMG = path.join(__dirname, '../fixtures/small-image.jpg');
|
||||
const uploadsDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
@@ -44,13 +53,13 @@ beforeAll(async () => {
|
||||
nestApp = await buildApp();
|
||||
app = nestApp.getHttpAdapter().getInstance();
|
||||
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
await setAppSetting(orm(), 'allowed_file_types', '*');
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetTestDb(testDb);
|
||||
await resetRateLimits(nestApp);
|
||||
testDb.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('allowed_file_types', '*')").run();
|
||||
await setAppSetting(orm(), 'allowed_file_types', '*');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -108,7 +117,7 @@ describe('API key encryption', () => {
|
||||
.set('Cookie', authCookie(user.id))
|
||||
.send({ openweather_api_key: 'test-api-key-12345' });
|
||||
|
||||
const row = testDb.prepare('SELECT openweather_api_key FROM users WHERE id = ?').get(user.id) as any;
|
||||
const row = await readUser(orm(), user.id);
|
||||
expect(row.openweather_api_key).toMatch(/^enc:v1:/);
|
||||
});
|
||||
|
||||
@@ -126,19 +135,16 @@ describe('API key encryption', () => {
|
||||
// changedKeys is internal: the client body is what it always was.
|
||||
expect(first.body).not.toHaveProperty('changedKeys');
|
||||
|
||||
const rows = () =>
|
||||
testDb
|
||||
.prepare("SELECT details FROM audit_log WHERE action = 'settings.api_keys_update'")
|
||||
.all() as { details: string | null }[];
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(rows()[0].details).toContain('openweather_api_key');
|
||||
expect(rows()[0].details).not.toContain('test-api-key-12345');
|
||||
const rows = () => findRows(orm(), AuditLog, { action: 'settings.api_keys_update' });
|
||||
expect(await rows()).toHaveLength(1);
|
||||
expect((await rows())[0].details).toContain('openweather_api_key');
|
||||
expect((await rows())[0].details).not.toContain('test-api-key-12345');
|
||||
|
||||
// The same value again writes no second row. This is the real test of the
|
||||
// cleartext comparison: encryption uses a random IV, so the stored blob
|
||||
// differs on every save even when the key does not.
|
||||
await save();
|
||||
expect(rows()).toHaveLength(1);
|
||||
expect(await rows()).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('SEC-008 — GET /api/auth/me does not return plaintext API key', async () => {
|
||||
@@ -192,7 +198,7 @@ describe('File download path traversal', () => {
|
||||
expect(upload.status).toBe(201);
|
||||
const fileId = upload.body.file.id;
|
||||
|
||||
testDb.prepare('UPDATE trip_files SET filename = ? WHERE id = ?').run('../../etc/passwd', fileId);
|
||||
await updateRows(orm(), TripFiles, { id: fileId }, { filename: '../../etc/passwd' });
|
||||
|
||||
const res = await request(app)
|
||||
.get(`/api/trips/${trip.id}/files/${fileId}/download`)
|
||||
|
||||
Reference in New Issue
Block a user