mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
724c88c201
A mechanical prettier --write over server/src, server/tests, server/scripts and shared/src with the workspaces' .prettierrc; nothing but layout and import order changes, and every file keeps the same set of imports. shared is formatted throughout and its format baseline is empty. The server baseline keeps the 35 files that Prettier would grow past their lint:size limit (they need a split first, which is not a formatting change), and the generated entity files stay as their generator writes them.
219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
import { openDatabase } from '../../../src/db/connection';
|
|
import { Places } from '../../../src/db/entities/Places.entity';
|
|
import { Trips } from '../../../src/db/entities/Trips.entity';
|
|
import { UnitOfWork } from '../../../src/nest/database/unit-of-work';
|
|
import { TripsService } from '../../../src/nest/trips/trips.service';
|
|
import { createSnapshotTestDb } from '../../helpers/db-mock';
|
|
import {
|
|
createDayAssignment,
|
|
createDayNote,
|
|
createPackingItem,
|
|
createPlace,
|
|
createTrip,
|
|
createUser,
|
|
} from '../../helpers/factories';
|
|
import { countRows } from '../../helpers/factories/rows';
|
|
import { readTripDays } from '../../helpers/factories/trips';
|
|
import { createTestOrm } from '../../helpers/test-orm';
|
|
|
|
import Database from 'better-sqlite3';
|
|
import fs from 'fs';
|
|
import os from 'os';
|
|
import path from 'path';
|
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
|
|
|
// #2518: a container with a read-only root filesystem and no tmpfs on /tmp
|
|
// gives SQLite nowhere to put a temp file, so deleting a trip failed with
|
|
// SQLITE_IOERR_GETTEMPPATH. Every connection TREK opens keeps its temp storage
|
|
// in memory now, and these tests pin that down.
|
|
|
|
const SERVER_ROOT = path.resolve(__dirname, '../../..');
|
|
const MEMORY = 2; // PRAGMA temp_store: 0 default, 1 file, 2 memory
|
|
|
|
let tmpDir: string;
|
|
let schemaFile: string;
|
|
|
|
beforeAll(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'trek-connection-'));
|
|
// Real schema, built once and copied per test: the delete below has to
|
|
// cascade through the same tables and foreign keys a live install has.
|
|
// The schema is the migrated snapshot the vitest global setup built, written
|
|
// out as a file so every copy is a real on-disk database.
|
|
schemaFile = path.join(tmpDir, 'schema.db');
|
|
const snapshot = createSnapshotTestDb();
|
|
fs.writeFileSync(schemaFile, snapshot.serialize());
|
|
snapshot.close();
|
|
const db = openDatabase(schemaFile);
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
db.exec('PRAGMA wal_checkpoint(TRUNCATE)');
|
|
db.close();
|
|
});
|
|
|
|
afterAll(() => {
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
});
|
|
|
|
function freshCopy(name: string): string {
|
|
const file = path.join(tmpDir, `${name}.db`);
|
|
fs.copyFileSync(schemaFile, file);
|
|
return file;
|
|
}
|
|
|
|
describe('openDatabase', () => {
|
|
it('keeps temp storage in memory on a read-write handle', () => {
|
|
const db = openDatabase(freshCopy('rw'));
|
|
try {
|
|
expect(db.pragma('temp_store', { simple: true })).toBe(MEMORY);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('keeps temp storage in memory on a read-only handle', () => {
|
|
const db = openDatabase(freshCopy('ro'), { readonly: true });
|
|
try {
|
|
expect(db.readonly).toBe(true);
|
|
expect(db.pragma('temp_store', { simple: true })).toBe(MEMORY);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('is what the server opens its main database with', async () => {
|
|
const { db } = await import('../../../src/db/database');
|
|
expect(db.pragma('temp_store', { simple: true })).toBe(MEMORY);
|
|
});
|
|
});
|
|
|
|
// SQLite names every temp file etilqs_<random>. Linux unlinks the file the
|
|
// moment it is opened, so it only shows among the process's open descriptors;
|
|
// Windows keeps it in the temp directory until it is closed. macOS offers
|
|
// neither view, which is why the test below does not run there.
|
|
const canSeeTempFiles = process.platform === 'linux' || process.platform === 'win32';
|
|
|
|
function sqliteTempFiles(): string[] {
|
|
if (process.platform === 'linux') {
|
|
return fs
|
|
.readdirSync('/proc/self/fd')
|
|
.flatMap((fd) => {
|
|
try {
|
|
return [fs.readlinkSync(`/proc/self/fd/${fd}`)];
|
|
} catch {
|
|
return [];
|
|
}
|
|
})
|
|
.filter((target) => target.includes('etilqs_'));
|
|
}
|
|
return fs.readdirSync(os.tmpdir()).filter((name) => name.startsWith('etilqs_'));
|
|
}
|
|
|
|
/**
|
|
* Builds a trip the size of a real one on `db`, deletes it through
|
|
* TripsService.remove and reports which SQLite temp files were open while the
|
|
* delete ran. A trigger calls back into the test after the cascade, still
|
|
* inside the statement, which is when a spilled statement journal is open.
|
|
*/
|
|
async function deleteTripAndWatchTempFiles(db: Database.Database): Promise<{
|
|
tempFiles: string[];
|
|
tripLeft: number;
|
|
placesLeft: number;
|
|
}> {
|
|
db.exec('PRAGMA journal_mode = WAL');
|
|
db.exec('PRAGMA foreign_keys = ON');
|
|
|
|
const { user } = createUser(db);
|
|
const trip = createTrip(db, user.id, {
|
|
title: 'Niederlande Hoofddorp',
|
|
start_date: '2026-06-01',
|
|
end_date: '2026-06-14',
|
|
});
|
|
const t = await createTestOrm(db);
|
|
const days = await readTripDays(t, trip.id);
|
|
for (let i = 0; i < 25; i++) {
|
|
const place = createPlace(db, trip.id, { name: `Ort ${i}`, description: 'Grachten und Tulpen. '.repeat(10) });
|
|
createDayAssignment(db, days[i % days.length].id, place.id);
|
|
}
|
|
days.forEach((day) => createDayNote(db, day.id, trip.id));
|
|
for (let i = 0; i < 20; i++) createPackingItem(db, trip.id, { name: `Teil ${i}` });
|
|
|
|
const before = new Set(sqliteTempFiles());
|
|
const seen = new Set<string>();
|
|
db.function('trek_watch_temp_files', () => {
|
|
for (const file of sqliteTempFiles()) if (!before.has(file)) seen.add(file);
|
|
return null;
|
|
});
|
|
db.exec('CREATE TRIGGER trek_watch_trip_delete AFTER DELETE ON trips BEGIN SELECT trek_watch_temp_files(); END');
|
|
|
|
const none = undefined as never;
|
|
try {
|
|
const trips = new TripsService(none, none, none, none, none, none, none, none, new UnitOfWork(t.em), t.em, none);
|
|
await trips.remove(trip.id, user.id, 'user');
|
|
return {
|
|
tempFiles: [...seen],
|
|
tripLeft: await countRows(t, Trips, { id: trip.id }),
|
|
placesLeft: await countRows(t, Places, { trip: trip.id }),
|
|
};
|
|
} finally {
|
|
await t.close();
|
|
}
|
|
}
|
|
|
|
describe.runIf(canSeeTempFiles)('deleting a trip with content (#2518)', () => {
|
|
it('needs a temp file on a connection left at the SQLite default', async () => {
|
|
// The control: proves the watcher sees temp files, and that this delete is
|
|
// one SQLite cannot finish without one where no temp directory is writable.
|
|
const db = new Database(freshCopy('default'));
|
|
try {
|
|
const result = await deleteTripAndWatchTempFiles(db);
|
|
expect(result.tempFiles.length).toBeGreaterThan(0);
|
|
expect(result.tripLeft).toBe(0);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
|
|
it('never touches a temp file on a connection TREK opens', async () => {
|
|
const db = openDatabase(freshCopy('trek'));
|
|
try {
|
|
const result = await deleteTripAndWatchTempFiles(db);
|
|
expect(result.tempFiles).toEqual([]);
|
|
expect(result.tripLeft).toBe(0);
|
|
expect(result.placesLeft).toBe(0);
|
|
} finally {
|
|
db.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('every SQLite connection goes through openDatabase', () => {
|
|
function sourceFiles(dir: string): string[] {
|
|
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((entry) => {
|
|
const full = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) return sourceFiles(full);
|
|
return entry.name.endsWith('.ts') ? [full] : [];
|
|
});
|
|
}
|
|
|
|
it('leaves no other place in src/ constructing a better-sqlite3 handle', () => {
|
|
const offenders = sourceFiles(path.join(SERVER_ROOT, 'src')).filter((file) => {
|
|
if (file === path.join(SERVER_ROOT, 'src', 'db', 'connection.ts')) return false;
|
|
const source = fs.readFileSync(file, 'utf8');
|
|
// A value import (not `import type`) under whatever local name, then `new` on it.
|
|
const imported = /import\s+(?!type\b)(\w+)[^;]*?from\s+['"]better-sqlite3['"]/.exec(source)?.[1];
|
|
const required = /(\w+)\s*=\s*require\(\s*['"]better-sqlite3['"]\s*\)/.exec(source)?.[1];
|
|
return [imported, required].some((name) => name && new RegExp(`new\\s+${name}\\s*\\(`).test(source));
|
|
});
|
|
expect(offenders.map((file) => path.relative(SERVER_ROOT, file))).toEqual([]);
|
|
});
|
|
|
|
it('is mirrored by the standalone scripts that cannot import it', () => {
|
|
// reset-admin.js and the key rotation run in the container without src/,
|
|
// so they set the pragma themselves.
|
|
for (const script of ['reset-admin.js', 'scripts/migrate-encryption.ts']) {
|
|
const source = fs.readFileSync(path.join(SERVER_ROOT, script), 'utf8');
|
|
expect(source, script).toMatch(/PRAGMA temp_store = MEMORY|pragma\('temp_store = MEMORY'\)/);
|
|
}
|
|
});
|
|
});
|