From 718022c7bafd31487dd6bf3f2e09ecf9f31a4804 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 25 Jul 2026 18:14:37 +0200 Subject: [PATCH] feat(db): provide better-sqlite3 connection via DI token and complete DatabaseService surface --- server/src/db/database.ts | 1 + server/src/nest/database/database.module.ts | 10 +++-- server/src/nest/database/database.service.ts | 45 ++++++++++++++----- server/src/nest/database/database.tokens.ts | 6 +++ .../nest/database-service.delegation.test.ts | 39 ++++++++++++++++ .../tests/unit/nest/database-service.test.ts | 40 ++++++++++++++++- server/tests/unit/nest/wiring.test.ts | 3 +- 7 files changed, 128 insertions(+), 16 deletions(-) create mode 100644 server/src/nest/database/database.tokens.ts create mode 100644 server/tests/unit/nest/database-service.delegation.test.ts diff --git a/server/src/db/database.ts b/server/src/db/database.ts index 4a1aed293..a7e5b82ef 100644 --- a/server/src/db/database.ts +++ b/server/src/db/database.ts @@ -167,3 +167,4 @@ try { } export { db, closeDb, reinitialize, getPlaceWithTags, canAccessTrip, isOwner }; +export type { TripAccess, PlaceWithTags }; diff --git a/server/src/nest/database/database.module.ts b/server/src/nest/database/database.module.ts index e805238b6..8ce3fc4a9 100644 --- a/server/src/nest/database/database.module.ts +++ b/server/src/nest/database/database.module.ts @@ -1,13 +1,17 @@ import { Global, Module } from '@nestjs/common'; +import { db } from '../../db/database'; import { DatabaseService } from './database.service'; +import { DATABASE_CONNECTION } from './database.tokens'; /** * Global so every migrated module can inject DatabaseService without re-importing. - * Wraps the existing better-sqlite3 singleton (no new connection). + * DATABASE_CONNECTION resolves to the existing better-sqlite3 singleton Proxy + * (no new connection, single writer preserved); tests can override the token + * to hand the container a throwaway connection. */ @Global() @Module({ - providers: [DatabaseService], - exports: [DatabaseService], + providers: [{ provide: DATABASE_CONNECTION, useFactory: () => db }, DatabaseService], + exports: [DatabaseService, DATABASE_CONNECTION], }) export class DatabaseModule {} diff --git a/server/src/nest/database/database.service.ts b/server/src/nest/database/database.service.ts index b43731b89..0d18ea549 100644 --- a/server/src/nest/database/database.service.ts +++ b/server/src/nest/database/database.service.ts @@ -1,39 +1,62 @@ -import { Injectable } from '@nestjs/common'; +import { Inject, Injectable } from '@nestjs/common'; import type Database from 'better-sqlite3'; -import { db } from '../../db/database'; +import { canAccessTrip, getPlaceWithTags, isOwner } from '../../db/database'; +import type { PlaceWithTags, TripAccess } from '../../db/database'; +import { DATABASE_CONNECTION } from './database.tokens'; + +export type { PlaceWithTags, TripAccess }; /** * Injectable wrapper around TREK's existing better-sqlite3 connection. * - * `db` is a Proxy onto the singleton connection the legacy app already uses - * (WAL enabled), so Nest modules share the exact same connection — no second - * connection, no split state, single writer preserved. + * The injected connection is the Proxy onto the singleton the legacy app + * already uses (WAL enabled), so Nest modules share the exact same + * connection — no second connection, no split state, single writer preserved. */ @Injectable() export class DatabaseService { + constructor(@Inject(DATABASE_CONNECTION) private readonly conn: Database.Database) {} + /** The shared better-sqlite3 connection (same singleton the legacy app uses). */ get connection(): Database.Database { - return db; + return this.conn; } prepare(sql: string): Database.Statement { - return db.prepare(sql); + return this.conn.prepare(sql); } get(sql: string, ...params: unknown[]): T | undefined { - return db.prepare(sql).get(...params) as T | undefined; + return this.conn.prepare(sql).get(...params) as T | undefined; } all(sql: string, ...params: unknown[]): T[] { - return db.prepare(sql).all(...params) as T[]; + return this.conn.prepare(sql).all(...params) as T[]; } run(sql: string, ...params: unknown[]): Database.RunResult { - return db.prepare(sql).run(...params); + return this.conn.prepare(sql).run(...params); } /** Run `fn` inside a synchronous better-sqlite3 transaction. */ transaction(fn: (conn: Database.Database) => T): T { - return db.transaction(() => fn(db))(); + return this.conn.transaction(() => fn(this.conn))(); + } + + // Trip-access helpers delegate to the db/database exports (not this.conn): + // tests vi.mock that module and stub the helpers themselves, so the stubs + // must keep flowing through here. + + /** Trip visible to the user (owner or member); undefined when no access. */ + canAccessTrip(tripId: number | string, userId: number): TripAccess | undefined { + return canAccessTrip(tripId, userId); + } + + isOwner(tripId: number | string, userId: number): boolean { + return isOwner(tripId, userId); + } + + getPlaceWithTags(placeId: number | string): PlaceWithTags | null { + return getPlaceWithTags(placeId); } } diff --git a/server/src/nest/database/database.tokens.ts b/server/src/nest/database/database.tokens.ts new file mode 100644 index 000000000..4c551a516 --- /dev/null +++ b/server/src/nest/database/database.tokens.ts @@ -0,0 +1,6 @@ +/** + * Injection token for the shared better-sqlite3 connection. Provided by + * DatabaseModule from the existing singleton Proxy in src/db/database.ts, + * so it stays valid across backup-restore/demo-reset connection swaps. + */ +export const DATABASE_CONNECTION = Symbol('DATABASE_CONNECTION'); diff --git a/server/tests/unit/nest/database-service.delegation.test.ts b/server/tests/unit/nest/database-service.delegation.test.ts new file mode 100644 index 000000000..8e1485fe5 --- /dev/null +++ b/server/tests/unit/nest/database-service.delegation.test.ts @@ -0,0 +1,39 @@ +/** + * Locks in the delegation direction: DatabaseService's trip-access helpers + * must call the db/database exports (which e2e suites stub in their vi.mock + * factories), never reimplement the SQL against the injected connection. + */ +import { describe, it, expect, vi } from 'vitest'; + +const { canAccessTrip, isOwner, getPlaceWithTags } = vi.hoisted(() => ({ + canAccessTrip: vi.fn(() => ({ id: -1, user_id: -2, currency: 'XXX' })), + isOwner: vi.fn(() => true), + getPlaceWithTags: vi.fn(() => null), +})); + +vi.mock('../../../src/db/database', () => ({ + db: {}, + closeDb: () => {}, + reinitialize: () => {}, + canAccessTrip, + isOwner, + getPlaceWithTags, +})); + +import { DatabaseService } from '../../../src/nest/database/database.service'; + +describe('DatabaseService (helper delegation)', () => { + it('routes trip-access helpers through the db/database exports', async () => { + const { db } = await import('../../../src/db/database'); + const svc = new DatabaseService(db); + + expect(svc.canAccessTrip(7, 8)).toEqual({ id: -1, user_id: -2, currency: 'XXX' }); + expect(canAccessTrip).toHaveBeenCalledWith(7, 8); + + expect(svc.isOwner(7, 8)).toBe(true); + expect(isOwner).toHaveBeenCalledWith(7, 8); + + expect(svc.getPlaceWithTags(9)).toBeNull(); + expect(getPlaceWithTags).toHaveBeenCalledWith(9); + }); +}); diff --git a/server/tests/unit/nest/database-service.test.ts b/server/tests/unit/nest/database-service.test.ts index fdfe5a157..05575f673 100644 --- a/server/tests/unit/nest/database-service.test.ts +++ b/server/tests/unit/nest/database-service.test.ts @@ -3,10 +3,11 @@ * helper against the real connection so the typed query surface is covered. */ import { describe, it, expect } from 'vitest'; +import { db } from '../../../src/db/database'; import { DatabaseService } from '../../../src/nest/database/database.service'; describe('DatabaseService (typed query helpers)', () => { - const svc = new DatabaseService(); + const svc = new DatabaseService(db); it('exposes the shared connection', () => { expect(typeof svc.connection.prepare).toBe('function'); @@ -34,3 +35,40 @@ describe('DatabaseService (typed query helpers)', () => { svc.run('DROP TABLE _dbsvc_test'); }); }); + +describe('DatabaseService (trip-access helpers)', () => { + const svc = new DatabaseService(db); + + it('canAccessTrip / isOwner / getPlaceWithTags delegate to the shared helpers', () => { + svc.run( + "INSERT INTO users (id, username, email, password_hash) VALUES (900, 'dbsvc-owner', 'dbsvc-owner@x.test', 'x')" + ); + svc.run( + "INSERT INTO users (id, username, email, password_hash) VALUES (901, 'dbsvc-member', 'dbsvc-member@x.test', 'x')" + ); + svc.run("INSERT INTO trips (id, user_id, title) VALUES (900, 900, 'DbSvc Trip')"); + svc.run("INSERT INTO trip_members (trip_id, user_id) VALUES (900, 901)"); + + expect(svc.canAccessTrip(900, 900)).toMatchObject({ id: 900, user_id: 900 }); + expect(svc.canAccessTrip(900, 901)).toMatchObject({ id: 900 }); + expect(svc.canAccessTrip(900, 999)).toBeUndefined(); + + expect(svc.isOwner(900, 900)).toBe(true); + expect(svc.isOwner(900, 901)).toBe(false); + + expect(svc.getPlaceWithTags(999999)).toBeNull(); + svc.run("INSERT INTO places (id, trip_id, name) VALUES (900, 900, 'DbSvc Place')"); + expect(svc.getPlaceWithTags(900)).toMatchObject({ + id: 900, + name: 'DbSvc Place', + category: null, + tags: [], + ratings: [], + rating_avg: null, + rating_count: 0, + }); + + svc.run('DELETE FROM trips WHERE id = 900'); + svc.run('DELETE FROM users WHERE id IN (900, 901)'); + }); +}); diff --git a/server/tests/unit/nest/wiring.test.ts b/server/tests/unit/nest/wiring.test.ts index 0dc64fab2..bfb97a4de 100644 --- a/server/tests/unit/nest/wiring.test.ts +++ b/server/tests/unit/nest/wiring.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { HttpException } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import { db } from '../../../src/db/database'; import { AppModule } from '../../../src/nest/app.module'; import { HealthController } from '../../../src/nest/health/health.controller'; import { DatabaseService } from '../../../src/nest/database/database.service'; @@ -33,7 +34,7 @@ describe('AdminGuard', () => { describe('DatabaseService (shared connection)', () => { it('runs real queries against the existing SQLite connection', () => { - const svc = new DatabaseService(); + const svc = new DatabaseService(db); expect(svc.get('SELECT 1 AS one')).toEqual({ one: 1 }); expect(svc.all('SELECT 1 AS one')).toEqual([{ one: 1 }]); });