diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cf461679b..ea659c4a4 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -266,6 +266,12 @@ jobs: # only shrink; @txStandalone/@txIndependent mark the deliberate ones. run: cd server && npm run lint:tx + - name: Raw SQL in tests may only shrink + # Counts the better-sqlite3 .prepare() calls under tests/ per file + # against scripts/test-sql-baseline.json. New fixtures seed and read + # through tests/helpers/factories (MikroORM) instead. + run: cd server && npm run lint:test-sql + - name: Open request schemas may only shrink # Needs the shared build above. Counts open shapes (records of # unknown, passthrough, any) in shared's request schemas. diff --git a/CLAUDE.md b/CLAUDE.md index d817087a6..2260e4b68 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -46,6 +46,7 @@ cd server && npm run lint:boundaries # no new import cycles or cro cd server && npm run lint:dialect # SQLite-only SQL spellings may only shrink (CI gate; rules in server/CLAUDE.md) cd server && npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job; needs TREK_PG_PROBE_URL) cd server && npm run lint:tx # no new method writing twice outside one transaction (CI gate) +cd server && npm run lint:test-sql # raw SQL fixtures in tests/ may only shrink; seed through tests/helpers/factories (CI gate) cd client && npm run theme:lint # flag styling that bypasses appearance tokens (local only) npm run typecheck --workspace=server # tsc --noEmit (also client/shared; server also has typecheck:tests) ``` @@ -120,7 +121,7 @@ These principles come out of a full-repo audit and shape all new code: ## Quality gates in CI -`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:boundaries` and `lint:tx`, the client's `lint:format`, `lint:layers`, `lint:offline`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. +`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:boundaries`, `lint:tx`, `lint:dialect` and `lint:test-sql`, the client's `lint:format`, `lint:layers`, `lint:offline`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags. - **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`. - **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there. diff --git a/server/CLAUDE.md b/server/CLAUDE.md index 6abfc4117..a4caa4f90 100644 --- a/server/CLAUDE.md +++ b/server/CLAUDE.md @@ -24,6 +24,7 @@ npm run lint:boundaries # import cycles and cross-domain reach-ins may only sh npm run lint:dialect # SQLite-only SQL spellings under src/ may only shrink (CI gate) npm run probe:pg # dialect helpers + the repository statements it reaches against Postgres (CI job postgres-probe; needs TREK_PG_PROBE_URL) npm run lint:tx # methods writing more than once outside one transaction may only shrink (CI gate) +npm run lint:test-sql # raw better-sqlite3 statements in tests/ may only shrink (CI gate) npm run contracts:dto-open # open shapes in the createZodDto schemas may only shrink (runs in the unit tests) ``` @@ -35,6 +36,8 @@ npm run contracts:dto-open # open shapes in the createZodDto schemas may only sh **`lint:size`** (`scripts/size-lint.mjs`) counts a line longer than 120 columns once per 120 columns it spans, so joining lines does not make room. Files that were already longer are held at their length in `scripts/size-baseline.json`; a file that needs to grow past its entry is split by concern instead. An entry above what its file holds now, or for a file that is gone, fails as well, so after a split `npm run lint:size -- --update` lowers the baseline in the same change (it never raises or adds an entry). +**`lint:test-sql`** (`scripts/test-sql-ratchet.mjs`) counts every `.prepare(` under `tests/` per file against `scripts/test-sql-baseline.json`: a file may hold at most its entry, a file without one none. Growth fails, and so does an entry above what its file holds now, so a conversion lands with `npm run lint:test-sql -- --update` (it only lowers and drops entries). Files whose subject is raw SQL (the dialect functions, PRAGMA introspection, the legacy upgrade fixtures) sit in the baseline's `exempt` list with a reason; a single statement that must stay raw carries a `test-sql-allow: ` comment on its line or the line above. `tests/unit/scripts/test-sql-ratchet.test.ts` also runs it on the tree. + Single test: `npx vitest run tests/unit/nest/weather.controller.test.ts`, or `npx vitest run -t "returns 401 without cookie"`. **Build emits even with type errors** (`scripts/build.mjs` catches `tsc` failures and warns). `npm run build` succeeding does **not** mean the code typechecks — run `npm run typecheck`. @@ -107,6 +110,8 @@ SQLite is the engine today and Postgres is the planned second one. Code under `s `tests/` is split into `unit/` (mirrors `src/`), `integration/`, `e2e/` (one `.e2e.test.ts` per module, booting the real guards against a temp DB via `tests/e2e/harness.ts`) and `websocket/`; helpers in `tests/helpers/`, fixtures in `tests/fixtures/`. +- **Seed and read through the ORM factories** in `tests/helpers/factories/` (`makeUser`, `makeTrip`, `makePlace`, … plus the typed `findRow`/`findRows`/`countRows`/`updateRows`/`deleteRows` readers), handed the app's ORM (`app.get(MikroORM)`) or a `createTestOrm(db)`. They go through MikroORM in a request context of their own, the way the app does, instead of raw SQLite on the sync handle; the README there has the before/after patterns. The raw `tests/helpers/factories.ts` and the `.prepare()` fixtures are legacy that `lint:test-sql` only lets shrink. + - **Upgrade fixtures**: `tests/fixtures/legacy/legacy-v*.sql` are databases the retired positional runner built; `tests/integration/legacy-upgrade-v*.test.ts` boot `buildApp()` on each. `legacy-v244` is the v4.3.3 release with rows across the main tables, rebuilt reproducibly by `node scripts/build-legacy-fixture-v244.mjs` (archives the tag into a temp dir, boots that release's runner, seeds, pins timestamps, dumps). - **vitest uses the SWC plugin**, not esbuild, because Nest's DI needs emitted decorator metadata. Keep that in `vitest.config.ts`. Pool is `forks` (isolated DB per worker). The config also aliases `@modelcontextprotocol/sdk/*` to its CJS dist because the SDK's exports map is unresolvable. diff --git a/server/package.json b/server/package.json index 1bc13e9e9..a6c6a7a3f 100644 --- a/server/package.json +++ b/server/package.json @@ -19,6 +19,7 @@ "lint:boundaries": "node scripts/import-boundaries.mjs", "lint:dialect": "node scripts/dialect-lint.mjs", "lint:tx": "node scripts/tx-writes.mjs", + "lint:test-sql": "node scripts/test-sql-ratchet.mjs", "contracts:dto-open": "node scripts/dto-open-shapes.mjs", "gen:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts", "check:plugin-facts": "node --import tsx scripts/gen-plugin-facts.ts --check", diff --git a/server/scripts/test-sql-baseline.json b/server/scripts/test-sql-baseline.json new file mode 100644 index 000000000..ed5ef7146 --- /dev/null +++ b/server/scripts/test-sql-baseline.json @@ -0,0 +1,354 @@ +{ + "exempt": { + "tests/helpers/legacy-fixture.ts": "Loads and fingerprints the legacy SQL fixtures, databases the retired positional runner built before any ORM schema existed.", + "tests/helpers/legacy-upgrade-suite.ts": "Reads legacy databases before and after the legacy baseline adopts them; the bookkeeping tables it checks (schema_version, mikro_orm_migrations) are the subject.", + "tests/integration/legacy-upgrade-v244.test.ts": "Compares a v4.3.3 database column by column before and after the upgrade, through pragma_table_info, on a copy no ORM is bound to.", + "tests/unit/db/dialect/sql-functions.test.ts": "Pins the SQL text and the engine result of every dialect function in src/db/dialect/sql-functions.ts; the raw statement is what is under test.", + "tests/unit/db/entity-schema-parity.test.ts": "Compares PRAGMA table_info, foreign_key_list and index_list of the migrated schema against the entity metadata.", + "tests/unit/db/foreign-key-indexes.test.ts": "Reads PRAGMA foreign_key_list, index_list and index_info and EXPLAIN QUERY PLAN to prove every foreign key is indexed.", + "tests/unit/db/migrate-encryption-parity.test.ts": "Seeds and reads a bare database for scripts/migrate-encryption.ts, which runs on the raw handle without the ORM.", + "tests/unit/db/nul-safe-sqlite-platform.test.ts": "Pins how the SQLite driver binds and returns the literals the platform has to escape (NUL, NaN, infinities).", + "tests/unit/db/pre-migrate-snapshot.test.ts": "Inspects the VACUUM INTO copy written before the migrator runs, a file no ORM is bound to.", + "tests/unit/db/restore-migrates-forward.test.ts": "Probes a restored database file before any ORM boots on it.", + "tests/unit/db/test-orm.test.ts": "Proves the test ORM shares the raw handle it was given; the raw statements are the subject." + }, + "counts": { + "tests/e2e/addons.e2e.test.ts": 10, + "tests/e2e/admin.e2e.test.ts": 7, + "tests/e2e/assignments.e2e.test.ts": 54, + "tests/e2e/atlas.e2e.test.ts": 14, + "tests/e2e/auth.e2e.test.ts": 10, + "tests/e2e/booking-import.e2e.test.ts": 3, + "tests/e2e/budget.e2e.test.ts": 27, + "tests/e2e/categories.e2e.test.ts": 3, + "tests/e2e/collab.e2e.test.ts": 26, + "tests/e2e/collections.e2e.test.ts": 10, + "tests/e2e/dawarich.e2e.test.ts": 14, + "tests/e2e/days.e2e.test.ts": 42, + "tests/e2e/doc-sync.e2e.test.ts": 11, + "tests/e2e/feeds.e2e.test.ts": 2, + "tests/e2e/files.e2e.test.ts": 6, + "tests/e2e/google-quota.e2e.test.ts": 3, + "tests/e2e/harness.ts": 1, + "tests/e2e/mailer.e2e.test.ts": 1, + "tests/e2e/notifications.e2e.test.ts": 4, + "tests/e2e/packing.e2e.test.ts": 24, + "tests/e2e/place-enrichment.e2e.test.ts": 3, + "tests/e2e/place-shadow.e2e.test.ts": 6, + "tests/e2e/places.e2e.test.ts": 48, + "tests/e2e/plugin-pois.e2e.test.ts": 2, + "tests/e2e/plugin-search.e2e.test.ts": 1, + "tests/e2e/public-api.e2e.test.ts": 28, + "tests/e2e/push.e2e.test.ts": 7, + "tests/e2e/receipt-scan.e2e.test.ts": 9, + "tests/e2e/reservations.e2e.test.ts": 36, + "tests/e2e/roadtrip.e2e.test.ts": 20, + "tests/e2e/school-holidays.e2e.test.ts": 4, + "tests/e2e/settings.e2e.test.ts": 11, + "tests/e2e/share.e2e.test.ts": 5, + "tests/e2e/storage-admin.e2e.test.ts": 7, + "tests/e2e/todo.e2e.test.ts": 8, + "tests/e2e/tours.e2e.test.ts": 34, + "tests/e2e/transit-provider.e2e.test.ts": 9, + "tests/e2e/trip-invite.e2e.test.ts": 6, + "tests/e2e/trips.e2e.test.ts": 23, + "tests/e2e/vacay.e2e.test.ts": 2, + "tests/helpers/db-mock.ts": 4, + "tests/helpers/factories.ts": 62, + "tests/helpers/test-db.ts": 5, + "tests/helpers/tours-repos.ts": 1, + "tests/integration/admin.test.ts": 62, + "tests/integration/airtrail-sync-request-context.test.ts": 2, + "tests/integration/assignments.test.ts": 5, + "tests/integration/async-closure-truthiness-guards.test.ts": 1, + "tests/integration/atlas-geocode-request-context.test.ts": 3, + "tests/integration/atlas.test.ts": 5, + "tests/integration/auth.test.ts": 75, + "tests/integration/budget.test.ts": 10, + "tests/integration/collections.test.ts": 3, + "tests/integration/dayReorder.test.ts": 5, + "tests/integration/days.test.ts": 46, + "tests/integration/doc-sync-webhook-request-context.test.ts": 2, + "tests/integration/feeds-request-context.test.ts": 3, + "tests/integration/files.test.ts": 2, + "tests/integration/immich.test.ts": 22, + "tests/integration/journey-place-order.test.ts": 6, + "tests/integration/journey.test.ts": 5, + "tests/integration/leg-mode-incoming.test.ts": 4, + "tests/integration/mcp.test.ts": 28, + "tests/integration/memories-immich.test.ts": 11, + "tests/integration/memories-synology.test.ts": 25, + "tests/integration/memories-unified.test.ts": 10, + "tests/integration/notifications.test.ts": 8, + "tests/integration/oauth-authorize.test.ts": 1, + "tests/integration/oauth.test.ts": 2, + "tests/integration/oidc.test.ts": 5, + "tests/integration/orm-request-context-seams.test.ts": 2, + "tests/integration/packing.test.ts": 5, + "tests/integration/passkey.test.ts": 4, + "tests/integration/places.test.ts": 32, + "tests/integration/plugins/boot-registry-order.test.ts": 5, + "tests/integration/plugins/dev-link.test.ts": 2, + "tests/integration/plugins/plugin-runtime.task2-conversion.test.ts": 23, + "tests/integration/plugins/plugin-runtime.test.ts": 67, + "tests/integration/plugins/plugin-scheduler-sweep.test.ts": 9, + "tests/integration/plugins/registry.test.ts": 18, + "tests/integration/profile.test.ts": 3, + "tests/integration/reservations.test.ts": 22, + "tests/integration/security.test.ts": 5, + "tests/integration/share.test.ts": 17, + "tests/integration/storage-restore-reload.test.ts": 2, + "tests/integration/systemNotices.test.ts": 23, + "tests/integration/todo.test.ts": 1, + "tests/integration/trip-access-primitives.test.ts": 5, + "tests/integration/trips.test.ts": 59, + "tests/integration/uploads-photo-request-context.test.ts": 3, + "tests/integration/vacay.test.ts": 2, + "tests/unit/db/booked-night-reseat.test.ts": 11, + "tests/unit/db/connection.test.ts": 3, + "tests/unit/db/durability.test.ts": 3, + "tests/unit/db/entities/primary-relations.spike.test.ts": 3, + "tests/unit/db/entity-class-fields.test.ts": 2, + "tests/unit/db/place-regions-follow-place.test.ts": 15, + "tests/unit/db/repositories/_shared/atlas-travowns-consumers.parity.test.ts": 8, + "tests/unit/db/repositories/_shared/collection-role.parity.test.ts": 2, + "tests/unit/db/repositories/_shared/owned-lookup.test.ts": 1, + "tests/unit/db/repositories/_shared/packing-visibility.parity.test.ts": 8, + "tests/unit/db/repositories/_shared/reservation-travelers-owns.parity.test.ts": 2, + "tests/unit/db/repositories/_shared/reservation-visibility.test.ts": 37, + "tests/unit/db/repositories/addons-config-encryption.test.ts": 1, + "tests/unit/db/repositories/Addons.repository.test.ts": 5, + "tests/unit/db/repositories/AppSettings.repository.test.ts": 6, + "tests/unit/db/repositories/AssignmentParticipants.repository.test.ts": 6, + "tests/unit/db/repositories/AuditLog.repository.test.ts": 10, + "tests/unit/db/repositories/bucket-list-duplicate.parity.test.ts": 2, + "tests/unit/db/repositories/BucketList.repository.test.ts": 12, + "tests/unit/db/repositories/BudgetItems.repository.test.ts": 5, + "tests/unit/db/repositories/Categories.repository.test.ts": 4, + "tests/unit/db/repositories/CollabMessages.repository.test.ts": 3, + "tests/unit/db/repositories/DawarichConnections.repository.test.ts": 4, + "tests/unit/db/repositories/DawarichVisitSuggestions.repository.test.ts": 11, + "tests/unit/db/repositories/DayAccommodations.repository.test.ts": 12, + "tests/unit/db/repositories/DayAssignments.repository.test.ts": 60, + "tests/unit/db/repositories/DayNotes.repository.test.ts": 10, + "tests/unit/db/repositories/Days.repository.test.ts": 9, + "tests/unit/db/repositories/DemoRepository.test.ts": 21, + "tests/unit/db/repositories/DocumentProviders.repository.test.ts": 3, + "tests/unit/db/repositories/DocumentSyncItems.repository.test.ts": 14, + "tests/unit/db/repositories/GooglePlacePhotoMeta.repository.test.ts": 12, + "tests/unit/db/repositories/HiddenRegions.repository.test.ts": 4, + "tests/unit/db/repositories/IdempotencyKeys.repository.test.ts": 9, + "tests/unit/db/repositories/identity-map-writeback.test.ts": 3, + "tests/unit/db/repositories/InviteTokens.repository.test.ts": 5, + "tests/unit/db/repositories/MaintenanceRepository.test.ts": 4, + "tests/unit/db/repositories/McpTokens.repository.test.ts": 11, + "tests/unit/db/repositories/NotificationChannelPreferences.repository.test.ts": 10, + "tests/unit/db/repositories/Notifications.repository.test.ts": 23, + "tests/unit/db/repositories/OauthClients.repository.test.ts": 7, + "tests/unit/db/repositories/OauthConsents.repository.test.ts": 9, + "tests/unit/db/repositories/OauthTokens.repository.test.ts": 31, + "tests/unit/db/repositories/PackingItems.repository.test.ts": 8, + "tests/unit/db/repositories/PasswordResetTokens.repository.test.ts": 4, + "tests/unit/db/repositories/PhotoProviderFields.repository.test.ts": 2, + "tests/unit/db/repositories/PhotoProviders.repository.test.ts": 4, + "tests/unit/db/repositories/PlaceDetailsCache.repository.test.ts": 8, + "tests/unit/db/repositories/PlaceRatings.repository.test.ts": 9, + "tests/unit/db/repositories/PlaceRegions.repository.test.ts": 7, + "tests/unit/db/repositories/Places.repository.test.ts": 94, + "tests/unit/db/repositories/PlaceShadowPicks.repository.test.ts": 12, + "tests/unit/db/repositories/PluginOauthTokens.repository.test.ts": 1, + "tests/unit/db/repositories/ReservationDayPositions.repository.test.ts": 4, + "tests/unit/db/repositories/ReservationEndpoints.repository.test.ts": 5, + "tests/unit/db/repositories/Reservations.repository.test.ts": 76, + "tests/unit/db/repositories/ReservationTravelers.repository.test.ts": 7, + "tests/unit/db/repositories/RoadtripDayTracks.repository.test.ts": 1, + "tests/unit/db/repositories/Settings.repository.test.ts": 8, + "tests/unit/db/repositories/ShareTokens.repository.test.ts": 10, + "tests/unit/db/repositories/Tags.repository.test.ts": 13, + "tests/unit/db/repositories/TodoItems.repository.test.ts": 3, + "tests/unit/db/repositories/Tours.repository.test.ts": 2, + "tests/unit/db/repositories/trek-repository.test.ts": 4, + "tests/unit/db/repositories/TripInviteTokens.repository.test.ts": 7, + "tests/unit/db/repositories/TripMembers.repository.test.ts": 34, + "tests/unit/db/repositories/Trips.repository.test.ts": 71, + "tests/unit/db/repositories/UserNoticeDismissals.repository.test.ts": 9, + "tests/unit/db/repositories/Users.repository.test.ts": 109, + "tests/unit/db/repositories/vacay-branch-coverage.test.ts": 10, + "tests/unit/db/repositories/VacayCompanyHolidays.repository.test.ts": 5, + "tests/unit/db/repositories/VacayHolidayCalendars.repository.test.ts": 4, + "tests/unit/db/repositories/VacayPlanMembers.repository.test.ts": 6, + "tests/unit/db/repositories/VacayPlans.repository.test.ts": 3, + "tests/unit/db/repositories/VacayShares.repository.test.ts": 11, + "tests/unit/db/repositories/VacayUserSettings.repository.test.ts": 4, + "tests/unit/db/repositories/VacayUserYears.repository.test.ts": 5, + "tests/unit/db/repositories/VacayYears.repository.test.ts": 5, + "tests/unit/db/repositories/VisitedCountries.repository.test.ts": 7, + "tests/unit/db/repositories/VisitedRegions.repository.test.ts": 9, + "tests/unit/db/repositories/WebauthnChallenges.repository.test.ts": 5, + "tests/unit/db/repositories/WebauthnCredentials.repository.test.ts": 6, + "tests/unit/db/seeds-admin.test.ts": 6, + "tests/unit/db/transaction-yield.test.ts": 8, + "tests/unit/db/trip-access.test.ts": 8, + "tests/unit/demo/demo-seed.test.ts": 6, + "tests/unit/helpers/test-uow.test.ts": 1, + "tests/unit/idempotency-cleanup.test.ts": 6, + "tests/unit/mcp/tools-airtrail.test.ts": 2, + "tests/unit/mcp/tools-assignments-reservations-extra.test.ts": 2, + "tests/unit/mcp/tools-assignments.test.ts": 21, + "tests/unit/mcp/tools-atlas-expanded.test.ts": 21, + "tests/unit/mcp/tools-atlas.test.ts": 5, + "tests/unit/mcp/tools-budget-advanced.test.ts": 13, + "tests/unit/mcp/tools-budget.test.ts": 30, + "tests/unit/mcp/tools-categories.test.ts": 19, + "tests/unit/mcp/tools-collab-polls-chat.test.ts": 11, + "tests/unit/mcp/tools-collections.test.ts": 16, + "tests/unit/mcp/tools-days-accommodations.test.ts": 20, + "tests/unit/mcp/tools-days.test.ts": 8, + "tests/unit/mcp/tools-feeds-invite.test.ts": 12, + "tests/unit/mcp/tools-files.test.ts": 18, + "tests/unit/mcp/tools-help-addons.test.ts": 4, + "tests/unit/mcp/tools-journey.test.ts": 3, + "tests/unit/mcp/tools-leg-transport-mode.test.ts": 5, + "tests/unit/mcp/tools-memories.test.ts": 6, + "tests/unit/mcp/tools-notes.test.ts": 22, + "tests/unit/mcp/tools-notifications.test.ts": 7, + "tests/unit/mcp/tools-packing-advanced.test.ts": 17, + "tests/unit/mcp/tools-packing.test.ts": 24, + "tests/unit/mcp/tools-places.test.ts": 34, + "tests/unit/mcp/tools-plugin-pois.test.ts": 2, + "tests/unit/mcp/tools-prompts.test.ts": 6, + "tests/unit/mcp/tools-reservations.test.ts": 16, + "tests/unit/mcp/tools-roadtrip.test.ts": 8, + "tests/unit/mcp/tools-settings.test.ts": 5, + "tests/unit/mcp/tools-share.test.ts": 2, + "tests/unit/mcp/tools-tags-maps-weather.test.ts": 9, + "tests/unit/mcp/tools-todos.test.ts": 7, + "tests/unit/mcp/tools-transit.test.ts": 11, + "tests/unit/mcp/tools-transports.test.ts": 22, + "tests/unit/mcp/tools-trip-members.test.ts": 32, + "tests/unit/mcp/tools-trips.test.ts": 49, + "tests/unit/mcp/tools-vacay.test.ts": 18, + "tests/unit/nest/accommodations.service.test.ts": 68, + "tests/unit/nest/addons.service.test.ts": 12, + "tests/unit/nest/admin.service.test.ts": 30, + "tests/unit/nest/admin.version-notification.test.ts": 5, + "tests/unit/nest/airtrail.service.test.ts": 1, + "tests/unit/nest/assignments.service.test.ts": 36, + "tests/unit/nest/atlas.service.test.ts": 46, + "tests/unit/nest/audit.service.test.ts": 6, + "tests/unit/nest/auth.service.test.ts": 73, + "tests/unit/nest/backup-archive-version.test.ts": 1, + "tests/unit/nest/boot-restore.test.ts": 2, + "tests/unit/nest/budget.service.calc.test.ts": 11, + "tests/unit/nest/budget.service.db.test.ts": 108, + "tests/unit/nest/budget.service.test.ts": 3, + "tests/unit/nest/calendar.service.test.ts": 88, + "tests/unit/nest/channel-override.test.ts": 1, + "tests/unit/nest/collab.service.test.ts": 35, + "tests/unit/nest/collections.service.test.ts": 62, + "tests/unit/nest/database/unit-of-work.test.ts": 1, + "tests/unit/nest/dawarich-suggestions.service.test.ts": 17, + "tests/unit/nest/dawarich-sync.service.test.ts": 27, + "tests/unit/nest/dawarich.service.test.ts": 2, + "tests/unit/nest/day-boundaries.test.ts": 1, + "tests/unit/nest/day-notes.service.test.ts": 4, + "tests/unit/nest/day-removal.service.test.ts": 24, + "tests/unit/nest/days.service.test.ts": 66, + "tests/unit/nest/doc-sync/doc-sync-config.service.test.ts": 23, + "tests/unit/nest/doc-sync/doc-sync.controller.test.ts": 18, + "tests/unit/nest/doc-sync/doc-sync.job.test.ts": 4, + "tests/unit/nest/doc-sync/doc-sync.service.test.ts": 50, + "tests/unit/nest/feeds.service.test.ts": 4, + "tests/unit/nest/files.service.test.ts": 26, + "tests/unit/nest/google-quota.service.test.ts": 7, + "tests/unit/nest/google-route.test.ts": 2, + "tests/unit/nest/idempotency.interceptor.test.ts": 2, + "tests/unit/nest/immich.service.test.ts": 5, + "tests/unit/nest/journey-book.service.test.ts": 8, + "tests/unit/nest/journey-domain.service.test.ts": 173, + "tests/unit/nest/journey-share.service.test.ts": 34, + "tests/unit/nest/journey-stats.service.test.ts": 7, + "tests/unit/nest/mailer.service.test.ts": 1, + "tests/unit/nest/mcp-shared.service.test.ts": 6, + "tests/unit/nest/memories-access.service.test.ts": 42, + "tests/unit/nest/notification-preferences.service.test.ts": 12, + "tests/unit/nest/notification-transports.test.ts": 3, + "tests/unit/nest/notifications.inapp-prefs.test.ts": 17, + "tests/unit/nest/notifications.service.test.ts": 45, + "tests/unit/nest/notifications/push-subscriptions.service.test.ts": 4, + "tests/unit/nest/notifications/vapid-keys.service.test.ts": 14, + "tests/unit/nest/notifications/web-push-dispatch.test.ts": 7, + "tests/unit/nest/notifications/web-push.service.test.ts": 7, + "tests/unit/nest/oauth.service.test.ts": 6, + "tests/unit/nest/oidc.service.test.ts": 48, + "tests/unit/nest/packing.service.test.ts": 56, + "tests/unit/nest/passkey.service.test.ts": 18, + "tests/unit/nest/permissions.service.test.ts": 12, + "tests/unit/nest/photo-resolver.service.test.ts": 4, + "tests/unit/nest/place-enrichment.service.test.ts": 1, + "tests/unit/nest/place-photos/place-photo-cache.service.test.ts": 14, + "tests/unit/nest/place-regions-repair.test.ts": 7, + "tests/unit/nest/place-shadow.service.test.ts": 9, + "tests/unit/nest/places.directions.test.ts": 1, + "tests/unit/nest/places.service.test.ts": 98, + "tests/unit/nest/platform.controller.test.ts": 3, + "tests/unit/nest/public-api-scopes.test.ts": 6, + "tests/unit/nest/query-helpers.service.test.ts": 4, + "tests/unit/nest/registration-invites.service.test.ts": 4, + "tests/unit/nest/reminder-jobs.service.test.ts": 15, + "tests/unit/nest/reservations.service.test.ts": 135, + "tests/unit/nest/roadtrip-plan.service.test.ts": 30, + "tests/unit/nest/roadtrip.service.test.ts": 2, + "tests/unit/nest/route-usage.service.test.ts": 10, + "tests/unit/nest/school-holidays.service.test.ts": 11, + "tests/unit/nest/settings.service.test.ts": 26, + "tests/unit/nest/settings/instance-api-keys.test.ts": 8, + "tests/unit/nest/share.service.test.ts": 49, + "tests/unit/nest/storage/storage-admin.service.test.ts": 3, + "tests/unit/nest/storage/storage-jobs.service.test.ts": 3, + "tests/unit/nest/storage/storage-registry.service.test.ts": 11, + "tests/unit/nest/storage/storage-stats.service.test.ts": 3, + "tests/unit/nest/synology.service.test.ts": 4, + "tests/unit/nest/thumbnail.service.test.ts": 3, + "tests/unit/nest/todo.service.test.ts": 7, + "tests/unit/nest/token.service.test.ts": 12, + "tests/unit/nest/tours.gpx.atomic.test.ts": 8, + "tests/unit/nest/tours.service.test.ts": 12, + "tests/unit/nest/travel-stats.service.test.ts": 6, + "tests/unit/nest/trek-photo-cache.service.test.ts": 13, + "tests/unit/nest/trek-photo-registration.service.test.ts": 9, + "tests/unit/nest/trip-invite.service.test.ts": 3, + "tests/unit/nest/trip-members.service.test.ts": 19, + "tests/unit/nest/trip-membership/trip-membership.service.test.ts": 8, + "tests/unit/nest/trip-read-model.service.test.ts": 10, + "tests/unit/nest/trips.service.test.ts": 208, + "tests/unit/nest/unified-memories.service.test.ts": 29, + "tests/unit/nest/user-cleanup.service.test.ts": 48, + "tests/unit/nest/user-profile.service.test.ts": 16, + "tests/unit/nest/vacay.service.test.ts": 93, + "tests/unit/plugins/discovery.test.ts": 12, + "tests/unit/plugins/host-rpc.test.ts": 38, + "tests/unit/plugins/operator-egress.test.ts": 5, + "tests/unit/plugins/plugin-audit.test.ts": 11, + "tests/unit/plugins/plugin-guards.test.ts": 2, + "tests/unit/plugins/plugin-host-state.test.ts": 1, + "tests/unit/plugins/plugin-instance-settings.test.ts": 17, + "tests/unit/plugins/plugin-oauth.service.test.ts": 13, + "tests/unit/plugins/plugin-pois.test.ts": 4, + "tests/unit/plugins/plugin-repository-sql-parity.test.ts": 20, + "tests/unit/plugins/plugin-route-normalize.test.ts": 2, + "tests/unit/plugins/plugin-routes.controller.test.ts": 1, + "tests/unit/plugins/plugin-runtime.boot-no-orm.test.ts": 1, + "tests/unit/plugins/plugin-user-settings.test.ts": 4, + "tests/unit/plugins/plugins-service.test.ts": 29, + "tests/unit/plugins/request-context.test.ts": 1, + "tests/unit/plugins/rpc-fallbacks.test.ts": 1, + "tests/unit/plugins/rpc-host-inv-strip.test.ts": 1, + "tests/unit/plugins/settings-isolation.test.ts": 9, + "tests/unit/services/airtrailImport.test.ts": 4, + "tests/unit/services/conflictUpdate.test.ts": 3, + "tests/unit/services/trekPhotoMedia.test.ts": 2, + "tests/unit/trips/trips.rpc.test.ts": 1, + "tests/websocket/connection.test.ts": 7 + } +} diff --git a/server/scripts/test-sql-ratchet.mjs b/server/scripts/test-sql-ratchet.mjs new file mode 100644 index 000000000..73980c018 --- /dev/null +++ b/server/scripts/test-sql-ratchet.mjs @@ -0,0 +1,201 @@ +#!/usr/bin/env node +/* + * lint:test-sql: raw better-sqlite3 statements in the server tests may only shrink. + * + * The suite used to seed and read its database through `.prepare()` on the + * synchronous better-sqlite3 handle: thousands of hand-written SQLite + * statements that no other driver can serve, which welds every test to one + * engine and bypasses the repositories the app goes through. The factories + * and readers in tests/helpers/factories/ do the same work through MikroORM + * (see the README there); this check makes sure the old way only recedes. + * + * Every `.prepare(` under tests/ is counted per file, mentions in comments + * included. A file may hold at most its entry in the `counts` of + * scripts/test-sql-baseline.json, and a file without an entry none at all. + * An entry above what its file holds now, or for a file that is gone, fails + * too until --update lowers it, so a converted file cannot grow back unseen. + * + * Two ways out, both explicit and reviewed: + * - a file whose subject is raw SQL itself (migrations, the legacy + * baseline, the dialect functions, PRAGMA introspection) is listed under + * `exempt` with the reason; it is not counted, and an exemption for a + * file that is gone fails; + * - a single statement that has to stay raw carries a comment with + * `test-sql-allow:` and a reason, on its own line or the line above. + * A marker with no reason does not count. + * + * npm run lint:test-sql check against the baseline (CI) + * npm run lint:test-sql -- --update lower the counts to what the files hold now; + * it never raises an entry, never adds one, + * and never touches the exemptions + * + * --dir= points the check at another server root (the unit tests use it). + */ +import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +/** The tree the check walks, relative to the server root. */ +export const ROOT = 'tests'; + +/** The baseline, relative to the server root. */ +export const BASELINE = 'scripts/test-sql-baseline.json'; + +const SOURCE = /\.(?:[cm]?[jt]s)$/; +const PREPARE = /\.prepare\(/g; +const ALLOW = /test-sql-allow:\s*\S/; + +/** + * The raw statements in one file's text: every `.prepare(` except those on a + * line that carries an allow marker with a reason, or right below one. + */ +export function countPrepares(text) { + const lines = text.replace(/\r\n?/g, '\n').split('\n'); + let total = 0; + lines.forEach((line, i) => { + const hits = line.match(PREPARE)?.length ?? 0; + if (hits === 0) return; + if (ALLOW.test(line) || (i > 0 && ALLOW.test(lines[i - 1]))) return; + total += hits; + }); + return total; +} + +function walk(dir, files = []) { + for (const name of readdirSync(dir)) { + if (name === 'node_modules') continue; + const path = join(dir, name); + if (statSync(path).isDirectory()) walk(path, files); + else if (SOURCE.test(name)) files.push(path); + } + return files; +} + +/** Raw statement counts keyed by the server-relative POSIX path, files without any left out. */ +export function scan(serverDir) { + const dir = join(serverDir, ROOT); + if (!existsSync(dir) || !statSync(dir).isDirectory()) { + throw new Error(`${ROOT}/ does not exist under ${serverDir}: the check would pass without looking at anything`); + } + const counts = {}; + for (const path of walk(dir)) { + const n = countPrepares(readFileSync(path, 'utf8')); + if (n > 0) counts[relative(serverDir, path).split('\\').join('/')] = n; + } + return counts; +} + +/** + * The baseline as committed. Missing, unreadable or malformed stops the run: + * read as empty, every file would fail with no hint at the cause, and + * --update would replace it with an empty one. + */ +export function readBaseline(path) { + let parsed; + try { + parsed = JSON.parse(readFileSync(path, 'utf8')); + } catch (err) { + throw new Error(`${BASELINE} cannot be read: ${err.message}`); + } + const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v); + if (!isObject(parsed) || !isObject(parsed.exempt) || !isObject(parsed.counts)) { + throw new Error(`${BASELINE} must be an object with an "exempt" and a "counts" object`); + } + for (const [file, reason] of Object.entries(parsed.exempt)) { + if (typeof reason !== 'string' || reason.trim() === '') { + throw new Error(`${BASELINE}: the exemption for ${file} needs a reason`); + } + if (file in parsed.counts) { + throw new Error(`${BASELINE}: ${file} is both exempt and counted`); + } + } + for (const [file, n] of Object.entries(parsed.counts)) { + if (!Number.isInteger(n) || n <= 0) { + throw new Error(`${BASELINE}: ${file} holds ${JSON.stringify(n)}, expected a whole number above 0`); + } + } + return parsed; +} + +/** The counts lowered to what the files hold now. Never raises an entry, never adds one. */ +export function lowerCounts(baseline, counts) { + const lowered = {}; + for (const [file, allowed] of Object.entries(baseline.counts)) { + const now = counts[file] ?? 0; + if (now > 0) lowered[file] = Math.min(allowed, now); + } + return Object.fromEntries(Object.entries(lowered).sort(([a], [b]) => a.localeCompare(b))); +} + +/** + * Files over their allowance; count entries above what their file holds now + * (exactly the ones lowerCounts would lower or drop); exemptions for files + * that are gone. + */ +export function compare(baseline, counts, serverDir) { + const exempt = baseline.exempt; + const grown = Object.entries(counts).filter(([file, n]) => !(file in exempt) && n > (baseline.counts[file] ?? 0)); + const lowered = lowerCounts(baseline, counts); + const stale = Object.entries(baseline.counts).filter(([file, n]) => lowered[file] !== n); + const goneExempt = Object.keys(exempt).filter((file) => !existsSync(join(serverDir, file))); + return { grown, stale, goneExempt }; +} + +function main(argv) { + const dirArg = argv.find((a) => a.startsWith('--dir=')); + const serverDir = dirArg ? resolve(dirArg.slice('--dir='.length)) : fileURLToPath(new URL('..', import.meta.url)); + const baselinePath = join(serverDir, BASELINE); + const update = argv.includes('--update'); + + const counts = scan(serverDir); + const baseline = readBaseline(baselinePath); + if (update) { + baseline.counts = lowerCounts(baseline, counts); + writeFileSync(baselinePath, JSON.stringify(baseline, null, 2) + '\n'); + } + + const { grown, stale, goneExempt } = compare(baseline, counts, serverDir); + for (const [file, n] of grown) { + const entry = baseline.counts[file]; + console.error( + `FAIL ${file}: ${n} raw statement(s), ` + + (entry ? `its baseline is ${entry}. ` : 'a file without a baseline entry may hold none. ') + + 'Seed and read through tests/helpers/factories/ instead (see the README there).', + ); + } + for (const [file, entry] of stale) { + console.error( + `FAIL ${file} is held at ${entry} in ${BASELINE}, ` + + (file in counts ? `but it has ${counts[file]} now.` : 'but it has none left (or the file is gone).'), + ); + } + for (const file of goneExempt) { + console.error(`FAIL ${file} is exempt in ${BASELINE}, but the file is gone: drop the exemption.`); + } + if (stale.length) { + console.error( + 'Run npm run lint:test-sql -- --update to lower the baseline with the change that made it smaller: ' + + 'an entry above the file lets it grow back unseen.', + ); + } + const total = Object.entries(counts) + .filter(([file]) => !(file in baseline.exempt)) + .reduce((sum, [, n]) => sum + n, 0); + console.log( + `test-sql: ${total} raw statement(s) in ${Object.keys(baseline.counts).length} baselined file(s), ` + + `${Object.keys(baseline.exempt).length} file(s) exempt`, + ); + return grown.length || stale.length || goneExempt.length ? 1 : 0; +} + +// Compared by real path, so the check still runs when the script is started through a symlink. +const isCli = + Boolean(process.argv[1]) && existsSync(process.argv[1]) && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url); +if (isCli) { + try { + process.exitCode = main(process.argv.slice(2)); + } catch (err) { + console.error(`FAIL ${err.message}`); + process.exitCode = 1; + } +} diff --git a/server/tests/e2e/calendar-feed-visibility.e2e.test.ts b/server/tests/e2e/calendar-feed-visibility.e2e.test.ts index e9050fc5e..a3bd79849 100644 --- a/server/tests/e2e/calendar-feed-visibility.e2e.test.ts +++ b/server/tests/e2e/calendar-feed-visibility.e2e.test.ts @@ -14,29 +14,24 @@ import type { Server } from 'http'; import { Test } from '@nestjs/testing'; vi.mock('../../src/db/database', async () => { - const { createSnapshotTestDb } = await import('../helpers/db-mock'); - const db = createSnapshotTestDb(); - return { - db, - canAccessTrip: (tripId: number | string, userId: number) => - db.prepare('SELECT id, user_id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), - isOwner: () => true, - getPlaceWithTags: () => null, - closeDb: () => {}, - reinitialize: () => {}, - }; + const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); + return buildDbMock(createSnapshotTestDb()); }); import { db } from '../../src/db/database'; import { FeedsModule } from '../../src/nest/feeds/feeds.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { makeUser } from '../helpers/factories/users'; +import { makeTrip, readTripDays } from '../helpers/factories/trips'; +import { makeReservation } from '../helpers/factories/reservations'; describe('Calendar feed visibility e2e (real CalendarService over temp SQLite)', () => { let server: Server; let app: Awaited>; let feedToken: string; + let orm: TestOrm; async function build() { const moduleRef = await Test.createTestingModule({ imports: [await TestUnitOfWorkModule.forRoot(db), await createTestMikroOrmModule(db), FeedsModule] }).compile(); @@ -47,18 +42,34 @@ describe('Calendar feed visibility e2e (real CalendarService over temp SQLite)', } beforeAll(async () => { - db.prepare( - "INSERT INTO users (id, username, email, password_hash, role) VALUES (1, 'e2e-user', 'e2e@example.test', 'x', 'user')", - ).run(); + // Seeded through MikroORM (tests/helpers/factories), not raw SQL. + orm = await createTestOrm(db); + const { user } = await makeUser(orm, { username: 'e2e-user', email: 'e2e@example.test' }); feedToken = 'feed-token-visibility'; - db.prepare( - "INSERT INTO trips (id, user_id, title, start_date, end_date, feed_token) VALUES (1, 1, 'Kyoto', '2026-09-01', '2026-09-05', ?)", - ).run(feedToken); - db.prepare("INSERT INTO days (id, trip_id, day_number, date) VALUES (1, 1, 1, '2026-09-01')").run(); - db.prepare(`INSERT INTO reservations (trip_id, day_id, title, type, status, reservation_time, confirmation_number, ingest_state) - VALUES (1, 1, 'Parked Flight', 'flight', 'confirmed', '2026-09-01T08:00', 'SECRET1', 'staged')`).run(); - db.prepare(`INSERT INTO reservations (trip_id, day_id, title, type, status, reservation_time, confirmation_number) - VALUES (1, 1, 'Booked Flight', 'flight', 'confirmed', '2026-09-01T12:00', 'OPEN1')`).run(); + const trip = await makeTrip(orm, user.id, { + title: 'Kyoto', + start_date: '2026-09-01', + end_date: '2026-09-05', + feed_token: feedToken, + }); + const [firstDay] = await readTripDays(orm, trip.id); + await makeReservation(orm, trip.id, { + day: firstDay.id, + title: 'Parked Flight', + type: 'flight', + status: 'confirmed', + reservation_time: '2026-09-01T08:00', + confirmation_number: 'SECRET1', + ingest_state: 'staged', + }); + await makeReservation(orm, trip.id, { + day: firstDay.id, + title: 'Booked Flight', + type: 'flight', + status: 'confirmed', + reservation_time: '2026-09-01T12:00', + confirmation_number: 'OPEN1', + }); app = await build(); server = app.getHttpServer(); @@ -66,6 +77,7 @@ describe('Calendar feed visibility e2e (real CalendarService over temp SQLite)', afterAll(async () => { await app.close(); + await orm.close(); }); it('the public feed serves the live booking and neither the staged one nor its confirmation number', async () => { diff --git a/server/tests/e2e/tags.e2e.test.ts b/server/tests/e2e/tags.e2e.test.ts index a3b08b010..cb279755c 100644 --- a/server/tests/e2e/tags.e2e.test.ts +++ b/server/tests/e2e/tags.e2e.test.ts @@ -1,8 +1,9 @@ /** - * Tags module e2e — exercises the migrated /api/tags endpoints through the real - * JwtAuthGuard against a temp SQLite db. TagsService runs its real SQL via - * DatabaseModule (the DATABASE_CONNECTION factory picks up the mocked db - * singleton); tags are user-scoped (no admin gate), so a normal authenticated + * Tags module e2e: exercises the migrated /api/tags endpoints through the real + * JwtAuthGuard against a real migrated temp SQLite db (createSnapshotTestDb(), + * no hand-rolled CREATE TABLEs). Rows are seeded and read through the + * factories in tests/helpers/factories, which go through MikroORM rather than + * raw SQL; tags are user-scoped (no admin gate), so a normal authenticated * user can do everything. */ import { describe, it, expect, beforeAll, beforeEach, afterAll, vi } from 'vitest'; @@ -10,41 +11,30 @@ import request from 'supertest'; import cookieParser from 'cookie-parser'; import type { Server } from 'http'; import { Test } from '@nestjs/testing'; -import { seedUser, sessionCookie } from './harness'; +import { sessionCookie } from './harness'; -const { db } = vi.hoisted(() => { - // eslint-disable-next-line @typescript-eslint/no-require-imports - const Database = require('better-sqlite3'); - const tmp = new Database(':memory:'); - tmp.exec('PRAGMA journal_mode = WAL'); - tmp.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT NOT NULL, - email TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'user', password_version INTEGER NOT NULL DEFAULT 0);`); - tmp.exec(`CREATE TABLE tags ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, - name TEXT NOT NULL, - color TEXT DEFAULT '#10b981', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP - );`); - // StorageRegistryService (behind StorageModule, now in this module chain) reads - // this at onModuleInit. - tmp.exec('CREATE TABLE app_settings (key TEXT PRIMARY KEY, value TEXT);'); - return { db: tmp }; +vi.mock('../../src/db/database', async () => { + const { createSnapshotTestDb, buildDbMock } = await import('../helpers/db-mock'); + return buildDbMock(createSnapshotTestDb()); }); -vi.mock('../../src/db/database', () => ({ db, closeDb: () => {}, reinitialize: () => {} })); - +import { db } from '../../src/db/database'; +import { Tags } from '../../src/db/entities/Tags.entity'; import { TagsModule } from '../../src/nest/tags/tags.module'; import { TrekExceptionFilter } from '../../src/nest/common/trek-exception.filter'; import { TestUnitOfWorkModule } from '../helpers/test-uow'; -import { createTestMikroOrmModule } from '../helpers/test-orm'; +import { createTestMikroOrmModule, createTestOrm, type TestOrm } from '../helpers/test-orm'; +import { makeUser } from '../helpers/factories/users'; +import { makeTag } from '../helpers/factories/places'; +import { deleteRows, findRow } from '../helpers/factories/rows'; -function insertTag(userId: number, name: string, color = '#10b981'): number { - const res = db.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(userId, name, color); - return Number(res.lastInsertRowid); +let orm: TestOrm; + +async function insertTag(userId: number, name: string, color = '#10b981'): Promise { + return (await makeTag(orm, userId, { name, color })).id; } -describe('Tags e2e (real auth guard + temp SQLite)', () => { +describe('Tags e2e (real auth guard + migrated temp SQLite)', () => { let server: Server; let app: Awaited>; @@ -58,18 +48,21 @@ describe('Tags e2e (real auth guard + temp SQLite)', () => { } beforeAll(async () => { - seedUser(db as never, { id: 1 }); - seedUser(db as never, { id: 2, email: 'e2e-2@example.test' }); + orm = await createTestOrm(db); + // Pinned ids: sessionCookie(1) and (2) sign for exactly these users. + await makeUser(orm, { id: 1, email: 'e2e@example.test' }); + await makeUser(orm, { id: 2, email: 'e2e-2@example.test' }); app = await build(); server = app.getHttpServer(); }); - beforeEach(() => { - db.exec('DELETE FROM tags'); + beforeEach(async () => { + await deleteRows(orm, Tags); }); afterAll(async () => { await app.close(); + await orm.close(); }); it('401 without a session cookie', async () => { @@ -78,9 +71,9 @@ describe('Tags e2e (real auth guard + temp SQLite)', () => { }); it('200 list scoped to the user, ordered by name', async () => { - insertTag(1, 'Zebra'); - insertTag(1, 'Apple'); - insertTag(2, 'Other-User-Tag'); + await insertTag(1, 'Zebra'); + await insertTag(1, 'Apple'); + await insertTag(2, 'Other-User-Tag'); const res = await request(server).get('/api/tags').set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body.tags.map((t: { name: string }) => t.name)).toEqual(['Apple', 'Zebra']); @@ -107,32 +100,32 @@ describe('Tags e2e (real auth guard + temp SQLite)', () => { }); it('200 on update of an owned tag, COALESCE preserving the color', async () => { - const id = insertTag(1, 'Beach', '#ff0000'); + const id = await insertTag(1, 'Beach', '#ff0000'); const res = await request(server).put(`/api/tags/${id}`).set('Cookie', sessionCookie(1)).send({ name: 'Hike' }); expect(res.status).toBe(200); expect(res.body.tag).toMatchObject({ id, name: 'Hike', color: '#ff0000' }); }); it('404 on update of a tag the user does not own', async () => { - const id = insertTag(2, 'Not-Yours'); + const id = await insertTag(2, 'Not-Yours'); const res = await request(server).put(`/api/tags/${id}`).set('Cookie', sessionCookie(1)).send({ name: 'X' }); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Tag not found' }); }); it('200 on delete of an owned tag, removing the row', async () => { - const id = insertTag(1, 'ToDelete'); + const id = await insertTag(1, 'ToDelete'); const res = await request(server).delete(`/api/tags/${id}`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(200); expect(res.body).toEqual({ success: true }); - expect(db.prepare('SELECT id FROM tags WHERE id = ?').get(id)).toBeUndefined(); + expect(await findRow(orm, Tags, { id })).toBeNull(); }); it('404 on delete of a tag the user does not own', async () => { - const id = insertTag(2, 'Not-Yours'); + const id = await insertTag(2, 'Not-Yours'); const res = await request(server).delete(`/api/tags/${id}`).set('Cookie', sessionCookie(1)); expect(res.status).toBe(404); expect(res.body).toEqual({ error: 'Tag not found' }); - expect(db.prepare('SELECT id FROM tags WHERE id = ?').get(id)).toBeDefined(); + expect(await findRow(orm, Tags, { id })).not.toBeNull(); }); }); diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts index ad7fa343d..298b88801 100644 --- a/server/tests/helpers/factories.ts +++ b/server/tests/helpers/factories.ts @@ -2,6 +2,10 @@ * Test data factories. * Each factory inserts a row into the provided in-memory DB and returns the created object. * Passwords are stored as bcrypt hashes (cost factor 4 for speed in tests). + * + * Legacy: these write raw SQLite on the sync handle. New fixtures use the ORM + * factories in ./factories/ (mostly the same names, `make` for `create`; see the README + * there), and `npm run lint:test-sql` only lets this file shrink. */ import Database from 'better-sqlite3'; diff --git a/server/tests/helpers/factories/README.md b/server/tests/helpers/factories/README.md new file mode 100644 index 000000000..7cb484a41 --- /dev/null +++ b/server/tests/helpers/factories/README.md @@ -0,0 +1,208 @@ +# Test factories over the ORM + +The server tests used to seed and read their database with raw SQLite on the +synchronous better-sqlite3 handle (`testDb.prepare(...).run(...)`): thousands of +hand-written statements that skip the repositories, restate the schema by hand +and run on no other engine. The helpers in this folder do the same work through +MikroORM, the way the app does, and are what new fixtures use. +`npm run lint:test-sql` holds the raw statements that are left to a shrinking +baseline (see [Ratchet](#ratchet)). + +## Getting an ORM handle + +Every helper takes a `FactoryOrm` (anything with the ORM's global `em`) as its +first argument and runs in a request context of its own, so the same call works +in every kind of suite: + +| Suite | Handle | +|---|---| +| `buildApp()` integration test | `const orm = nestApp.get(MikroORM)` (from `@mikro-orm/core`) after the app is built | +| partial-module e2e (`Test.createTestingModule`) | `const orm = await createTestOrm(db)` from `../helpers/test-orm`, closed with `await orm.close()` | +| unit test over `createSnapshotTestDb()` | `createTestOrm(testDb)`, as above | + +The handle must sit on the migrated schema (`createSnapshotTestDb()`): an +entity reads and writes every mapped column, so a hand-rolled `CREATE TABLE` +with a few columns is not enough. A suite that still builds its own tables +switches to the snapshot when it converts (`tests/e2e/tags.e2e.test.ts` is the +example). `resetTestDb(testDb)` between tests keeps working: each helper call +opens a fresh context, so no identity map remembers a row the reset deleted. + +## What is here + +One file per domain, each returning the row as the app reads it (`EntityDTO` +of the entity, the `SELECT *` shape with the `*_id` columns filled in): + +| File | Helpers | +|---|---| +| `users.ts` | `makeUser`, `makeAdmin`, `makeUserWithMfa`, `readUser`, `setImmichCredentials`, `setSynologyCredentials` | +| `trips.ts` | `makeTrip` (with one day per date), `makeDay`, `readTripDays`, `addTripMember`, `makeShareToken` | +| `places.ts` | `makePlace`, `makeCategory`, `makeTag`, `tagPlace` | +| `itinerary.ts` | `makeDayAssignment`, `makeDayNote`, `makeDayAccommodation` | +| `reservations.ts` | `makeReservation`, `makeReservationEndpoint`, `addReservationTraveler` | +| `budget.ts` | `makeBudgetItem`, `addBudgetItemMember`, `addBudgetItemPayer` | +| `packing.ts` | `makePackingItem`, `makePackingBag`, `addPackingBagMembers`, `addPackingItemRecipients` | +| `todos.ts` | `makeTodoItem` | +| `files.ts` | `makeTripFile`, `linkFile` | +| `journeys.ts` | `makeJourney` (owner added as contributor), `makeJourneyEntry`, `addJourneyContributor`, `linkTripToJourney` | +| `tours.ts` | `makeTour`, `addTourWaypoints` | +| `collections.ts` | `makeCollection`, `addCollectionMember`, `makeCollectionPlace`, `tagCollectionPlace`, `labelCollectionPlace` | +| `notifications.ts` | `makeNotification`, `setNotificationChannels`, `disableNotificationPref` | +| `settings.ts` | `setAppSetting`, `readAppSetting`, `setUserSetting`, `readUserSetting`, `setAddonEnabled`, `setCollabFeature` | +| `plugins.ts` | `makePlugin`, `makePluginSettingsField`, `setPluginUserConfig` | +| `tokens.ts` | `makeMcpToken`, `makeOauthClient`, `makeInviteToken` | +| `atlas.ts` | `makeBucketListItem`, `markCountryVisited`, `markRegionVisited` | +| `vacay.ts` | `makeVacayPlan`, `addVacayPlanMember`, `makeVacayEntry` | +| `photos.ts` | `addTripPhoto`, `addAlbumLink` | +| `collab.ts` | `makeCollabNote`, `makeCollabMessage` | + +Each `make*` has defaults that make a valid row on its own and takes an +`overrides` object of the entity's columns for everything else. Pivot tables +without an entity of their own (`place_tags`, `packing_bag_members`, …) are +written through the repository method the app uses for them. + +`rows.ts` holds the generic building blocks for any table, with or without a +dedicated factory: + +| Helper | Replaces | +|---|---| +| `createRow(orm, Entity, data)` | `INSERT` + `SELECT * WHERE id = lastInsertRowid` | +| `insertRow(orm, Entity, data)` / `insertRows` | a bare `INSERT` (returns the primary key) | +| `insertRowIgnoringConflict(orm, Entity, data)` | `INSERT OR IGNORE` | +| `upsertRow(orm, Entity, data, mergeFields?)` | `INSERT OR REPLACE`, `ON CONFLICT DO UPDATE` | +| `findRow(orm, Entity, where)` | `SELECT * ... LIMIT 1` (`null`, not `undefined`, when there is none) | +| `findRows(orm, Entity, where, orderBy?)` | `SELECT * ... ORDER BY` | +| `countRows(orm, Entity, where)` | `SELECT COUNT(*)` | +| `updateRows(orm, Entity, where, data)` | `UPDATE ... SET` (returns the affected count) | +| `deleteRows(orm, Entity, where)` | `DELETE FROM` (all rows for `{}`) | + +## Conversion guide + +### Write relations by their relation name + +Most foreign keys appear twice on an entity: the relation (`trip`, `user`, +`createdByRef`), which is written, and its `trip_id` twin, which is read only. +Write `{ trip: tripId }`, read `row.trip_id`. The helpers throw on a write to a +read-only twin and name the relation to use, because MikroORM would otherwise +drop the value without a word. + +### Insert a row + +```ts +// before +const res = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(userId, 'Beach', '#ff0000'); +const id = Number(res.lastInsertRowid); + +// after +const { id } = await makeTag(orm, userId, { name: 'Beach', color: '#ff0000' }); +// or, for a table without a factory +const row = await createRow(orm, Tags, { user: userId, name: 'Beach', color: '#ff0000' }); +``` + +The legacy `createUser(testDb)` / `createTrip(testDb, …)` from +`tests/helpers/factories.ts` map one to one onto `await makeUser(orm)` / +`await makeTrip(orm, …)`; the returned rows carry the same columns. + +### Read a row + +```ts +// before +const row = testDb.prepare('SELECT * FROM tags WHERE id = ?').get(id) as { name: string } | undefined; +expect(row).toBeUndefined(); + +// after +const row = await findRow(orm, Tags, { id }); +expect(row).toBeNull(); +``` + +### Count rows + +```ts +// before +const { n } = testDb.prepare('SELECT COUNT(*) AS n FROM tour_waypoints WHERE place_id = ?').get(placeId) as { n: number }; + +// after +const n = await countRows(orm, TourWaypoints, { place: placeId }); +``` + +### Update a row + +```ts +// before +testDb.prepare('UPDATE users SET must_change_password = 1 WHERE id = ?').run(user.id); + +// after +await updateRows(orm, Users, { id: user.id }, { must_change_password: 1 }); +``` + +### Delete rows between tests + +```ts +// before +beforeEach(() => db.exec('DELETE FROM tags')); + +// after +beforeEach(async () => { + await deleteRows(orm, Tags); +}); +``` + +### Worked examples + +These three were converted in full and show the setups side by side: + +- `tests/integration/categories.test.ts`: a `buildApp()` suite on the app's + own ORM (`nestApp.get(MikroORM)`). +- `tests/e2e/tags.e2e.test.ts`: a partial-module e2e that also moved from + hand-rolled tables to the migrated snapshot, with users pinned to the ids + its session cookies are signed for. +- `tests/e2e/calendar-feed-visibility.e2e.test.ts`: a partial-module e2e + seeding a user, a dated trip and staged and live bookings. + +## What stays raw + +Some tests are about raw SQL itself, and converting them would test nothing: + +- the dialect functions (`tests/unit/db/dialect/sql-functions.test.ts`), which + pin the SQL each function renders and what the engine returns for it; +- schema introspection: `PRAGMA table_info`, `foreign_key_list`, `index_list`, + `EXPLAIN QUERY PLAN`, `sqlite_master` (entity/schema parity, foreign-key + indexes, the table walk in `resetTestDb`); +- databases no ORM is bound to: the legacy upgrade fixtures and the legacy + baseline, the pre-migrate snapshot copy, a restored file before the boot, the + bare database `scripts/migrate-encryption.ts` works on; +- the driver itself (NUL and NaN binding, the shared connection behind + `createTestOrm`). + +Mark them in one of two ways: + +- **a whole file** whose subject is raw SQL goes into the `exempt` list of + `scripts/test-sql-baseline.json`, with the reason as its value; +- **a single statement** carries a comment with `test-sql-allow:` and a reason, + on its own line or on the line directly above: + + ```ts + // test-sql-allow: the table list comes from sqlite_master, which no entity or repository maps. + const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all(); + ``` + + A marker without a reason does not count. + +Seeding a migration test with the rows the migration is about to transform is +also raw by nature (the entities describe the schema after the migration); use +the allow marker there. + +## Ratchet + +`scripts/test-sql-ratchet.mjs` (`npm run lint:test-sql`, a CI gate) counts every +`.prepare(` under `tests/` per file, comments included, against the `counts` in +`scripts/test-sql-baseline.json`: + +- a file holding more than its entry fails, and a file without an entry may + hold none, so new fixtures go through the factories; +- an entry above what its file holds now fails too, so a conversion lands with + `npm run lint:test-sql -- --update`, which lowers and drops entries and never + raises or adds one; +- an exemption for a file that is gone fails until it is removed. + +When a file needs a raw statement that is not raw by nature, add a factory or a +reader here instead. diff --git a/server/tests/helpers/factories/atlas.ts b/server/tests/helpers/factories/atlas.ts new file mode 100644 index 000000000..e92092a7b --- /dev/null +++ b/server/tests/helpers/factories/atlas.ts @@ -0,0 +1,39 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { BucketList } from '../../../src/db/entities/BucketList.entity'; +import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity'; +import { VisitedRegions } from '../../../src/db/entities/VisitedRegions.entity'; +import type { FactoryOrm } from './context'; +import { createRow, insertRowIgnoringConflict } from './rows'; + +export type BucketListRow = EntityDTO; + +/** A destination on the user's bucket list. */ +export function makeBucketListItem( + orm: FactoryOrm, + userId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, BucketList, { + user: userId, + name: 'Test Destination', + lat: null, + lng: null, + country_code: null, + notes: null, + ...overrides, + }); +} + +/** Marks the country visited by hand; marking it twice is a no-op. The code is upper-cased as the atlas stores it. */ +export async function markCountryVisited(orm: FactoryOrm, userId: number, countryCode: string): Promise { + await insertRowIgnoringConflict(orm, VisitedCountries, { user: userId, country_code: countryCode.toUpperCase() }); +} + +/** Marks the region visited; marking it twice is a no-op. */ +export async function markRegionVisited( + orm: FactoryOrm, + userId: number, + region: { region_code: string; region_name: string; country_code: string }, +): Promise { + await insertRowIgnoringConflict(orm, VisitedRegions, { user: userId, ...region }); +} diff --git a/server/tests/helpers/factories/budget.ts b/server/tests/helpers/factories/budget.ts new file mode 100644 index 000000000..e23d1a453 --- /dev/null +++ b/server/tests/helpers/factories/budget.ts @@ -0,0 +1,43 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { BudgetItemMembers } from '../../../src/db/entities/BudgetItemMembers.entity'; +import { BudgetItemPayers } from '../../../src/db/entities/BudgetItemPayers.entity'; +import { BudgetItems } from '../../../src/db/entities/BudgetItems.entity'; +import type { FactoryOrm } from './context'; +import { createRow } from './rows'; + +export type BudgetItemRow = EntityDTO; + +/** An expense on the trip: "Test Budget Item", Transport, 100 in the trip currency. */ +export function makeBudgetItem( + orm: FactoryOrm, + tripId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, BudgetItems, { + trip: tripId, + name: 'Test Budget Item', + category: 'Transport', + total_price: 100, + ...overrides, + }); +} + +/** Adds the user to the expense's split, with an explicit share when `amount` is given. */ +export function addBudgetItemMember( + orm: FactoryOrm, + budgetItemId: number, + userId: number, + overrides: EntityData = {}, +): Promise> { + return createRow(orm, BudgetItemMembers, { budgetItem: budgetItemId, user: userId, ...overrides }); +} + +/** Records that the user paid `amount` of the expense. */ +export function addBudgetItemPayer( + orm: FactoryOrm, + budgetItemId: number, + userId: number, + amount: number, +): Promise> { + return createRow(orm, BudgetItemPayers, { budgetItem: budgetItemId, user: userId, amount }); +} diff --git a/server/tests/helpers/factories/collab.ts b/server/tests/helpers/factories/collab.ts new file mode 100644 index 000000000..34ea71206 --- /dev/null +++ b/server/tests/helpers/factories/collab.ts @@ -0,0 +1,36 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { CollabMessages } from '../../../src/db/entities/CollabMessages.entity'; +import { CollabNotes } from '../../../src/db/entities/CollabNotes.entity'; +import type { FactoryOrm } from './context'; +import { createRow } from './rows'; + +export type CollabNoteRow = EntityDTO; +export type CollabMessageRow = EntityDTO; + +/** A note on the trip's collab board, written by `userId`. */ +export function makeCollabNote( + orm: FactoryOrm, + tripId: number, + userId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, CollabNotes, { + trip: tripId, + user: userId, + title: 'Test Note', + content: null, + category: 'General', + color: '#6366f1', + ...overrides, + }); +} + +/** A chat message on the trip; `replyToRef` makes it a reply. */ +export function makeCollabMessage( + orm: FactoryOrm, + tripId: number, + userId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, CollabMessages, { trip: tripId, user: userId, text: 'Test message', ...overrides }); +} diff --git a/server/tests/helpers/factories/collections.ts b/server/tests/helpers/factories/collections.ts new file mode 100644 index 000000000..14656fafc --- /dev/null +++ b/server/tests/helpers/factories/collections.ts @@ -0,0 +1,65 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { CollectionMembers } from '../../../src/db/entities/CollectionMembers.entity'; +import { CollectionPlaces } from '../../../src/db/entities/CollectionPlaces.entity'; +import { Collections } from '../../../src/db/entities/Collections.entity'; +import { inContext, nextSeq, type FactoryOrm } from './context'; +import { createRow } from './rows'; + +export type CollectionRow = EntityDTO; +export type CollectionPlaceRow = EntityDTO; + +/** A collection owned by `ownerId`. */ +export function makeCollection( + orm: FactoryOrm, + ownerId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, Collections, { + owner: ownerId, + name: `Test Collection ${nextSeq('collection')}`, + ...overrides, + }); +} + +/** Invites the user into the collection; `status: 'accepted'` makes them a member right away. */ +export function addCollectionMember( + orm: FactoryOrm, + collectionId: number, + userId: number, + overrides: EntityData = {}, +): Promise> { + return createRow(orm, CollectionMembers, { + collection: collectionId, + user: userId, + status: 'accepted', + role: 'editor', + ...overrides, + }); +} + +/** A place saved into the collection by `ownerId`. */ +export function makeCollectionPlace( + orm: FactoryOrm, + collectionId: number, + ownerId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, CollectionPlaces, { + collection: collectionId, + owner: ownerId, + name: `Saved Place ${nextSeq('collection-place')}`, + lat: 48.8566, + lng: 2.3522, + ...overrides, + }); +} + +/** Puts the tags on the saved place. */ +export async function tagCollectionPlace(orm: FactoryOrm, collectionPlaceId: number, tagIds: number[]): Promise { + await inContext(orm, (em) => em.getRepository(CollectionPlaces).attachTags(collectionPlaceId, tagIds)); +} + +/** Files the saved place under the collection label. */ +export async function labelCollectionPlace(orm: FactoryOrm, collectionPlaceId: number, labelId: number): Promise { + await inContext(orm, (em) => em.getRepository(CollectionPlaces).assignLabel(collectionPlaceId, labelId)); +} diff --git a/server/tests/helpers/factories/context.ts b/server/tests/helpers/factories/context.ts new file mode 100644 index 000000000..b110d1b1b --- /dev/null +++ b/server/tests/helpers/factories/context.ts @@ -0,0 +1,37 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { withRequestContext } from '../../../src/nest/database/request-context'; + +/** + * Anything carrying the ORM's global EntityManager: `app.get(MikroORM)` in a + * `buildApp()` suite, the `TestOrm` from `createTestOrm(db)` in a unit or + * partial-module suite, or a `MikroORM` a test built itself. + */ +export interface FactoryOrm { + em: EntityManager; +} + +/** + * Runs one factory step in a request context of its own, the way production + * runs an MCP call or a cron tick (`withRequestContext`). + * + * The app's ORM keeps `allowGlobalContext: false`, so a write outside a + * context would be refused; and a fresh fork per step means no identity map + * survives into the next step, where `resetTestDb()` may already have deleted + * the rows it remembers. Inside the context `orm.em` resolves to that fork. + */ +export function inContext(orm: FactoryOrm, fn: (em: EntityManager) => Promise): Promise { + return withRequestContext(orm, () => fn(orm.em)); +} + +const sequences = new Map(); + +/** + * The next number for a named default (`user3@test.example.com`). Per worker + * process, never reset: like the legacy factories, names stay unique across + * the tests of one file even when `resetTestDb()` runs between them. + */ +export function nextSeq(name: string): number { + const n = (sequences.get(name) ?? 0) + 1; + sequences.set(name, n); + return n; +} diff --git a/server/tests/helpers/factories/files.ts b/server/tests/helpers/factories/files.ts new file mode 100644 index 000000000..a176a6ff5 --- /dev/null +++ b/server/tests/helpers/factories/files.ts @@ -0,0 +1,37 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { FileLinks } from '../../../src/db/entities/FileLinks.entity'; +import { TripFiles } from '../../../src/db/entities/TripFiles.entity'; +import { nextSeq, type FactoryOrm } from './context'; +import { createRow } from './rows'; + +export type TripFileRow = EntityDTO; +export type FileLinkRow = EntityDTO; + +/** + * A file row on the trip. Only the row: a test that downloads the file also + * writes the bytes through the storage fixture (`storage-fixture.ts`). + */ +export function makeTripFile( + orm: FactoryOrm, + tripId: number, + overrides: EntityData = {}, +): Promise { + const n = nextSeq('trip-file'); + return createRow(orm, TripFiles, { + trip: tripId, + filename: `test-file-${n}.pdf`, + original_name: `Test File ${n}.pdf`, + file_size: 1024, + mime_type: 'application/pdf', + ...overrides, + }); +} + +/** Links the file to a booking, an assignment, a place or an expense (name exactly one in `target`). */ +export function linkFile( + orm: FactoryOrm, + fileId: number, + target: Pick, 'reservation' | 'assignment' | 'place' | 'budgetItem'>, +): Promise { + return createRow(orm, FileLinks, { file: fileId, ...target }); +} diff --git a/server/tests/helpers/factories/itinerary.ts b/server/tests/helpers/factories/itinerary.ts new file mode 100644 index 000000000..71e142f5e --- /dev/null +++ b/server/tests/helpers/factories/itinerary.ts @@ -0,0 +1,78 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { DayAccommodations } from '../../../src/db/entities/DayAccommodations.entity'; +import { DayAssignments } from '../../../src/db/entities/DayAssignments.entity'; +import { DayNotes } from '../../../src/db/entities/DayNotes.entity'; +import { inContext, type FactoryOrm } from './context'; +import { createRow } from './rows'; + +export type DayAssignmentRow = EntityDTO; +export type DayNoteRow = EntityDTO; +export type DayAccommodationRow = EntityDTO; + +/** The next free `order_index` on the day: one past the highest, 0 on an empty day. */ +function nextOrderIndex(orm: FactoryOrm, dayId: number): Promise { + return inContext(orm, async (em) => { + const last = await em.findOne( + DayAssignments, + { day: dayId }, + { orderBy: { order_index: 'desc' }, disableIdentityMap: true }, + ); + return last?.order_index == null ? 0 : last.order_index + 1; + }); +} + +/** Schedules the place on the day, after whatever the day already holds. */ +export async function makeDayAssignment( + orm: FactoryOrm, + dayId: number, + placeId: number, + overrides: EntityData = {}, +): Promise { + const orderIndex = overrides.order_index ?? (await nextOrderIndex(orm, dayId)); + return createRow(orm, DayAssignments, { + day: dayId, + place: placeId, + notes: null, + ...overrides, + order_index: orderIndex, + }); +} + +/** A note on the day, sorted last unless `sort_order` says otherwise. */ +export function makeDayNote( + orm: FactoryOrm, + dayId: number, + tripId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, DayNotes, { + day: dayId, + trip: tripId, + text: 'Test note', + time: null, + icon: '📝', + sort_order: 9999, + ...overrides, + }); +} + +/** A stay at `placeId` from the start day to the end day. */ +export function makeDayAccommodation( + orm: FactoryOrm, + tripId: number, + placeId: number | null, + startDayId: number, + endDayId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, DayAccommodations, { + trip: tripId, + place: placeId, + startDay: startDayId, + endDay: endDayId, + check_in: null, + check_out: null, + confirmation: null, + ...overrides, + }); +} diff --git a/server/tests/helpers/factories/journeys.ts b/server/tests/helpers/factories/journeys.ts new file mode 100644 index 000000000..9b9003ea8 --- /dev/null +++ b/server/tests/helpers/factories/journeys.ts @@ -0,0 +1,70 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { JourneyContributors } from '../../../src/db/entities/JourneyContributors.entity'; +import { JourneyEntries } from '../../../src/db/entities/JourneyEntries.entity'; +import { JourneyTrips } from '../../../src/db/entities/JourneyTrips.entity'; +import { Journeys } from '../../../src/db/entities/Journeys.entity'; +import { nextSeq, type FactoryOrm } from './context'; +import { createRow, insertRow, insertRowIgnoringConflict } from './rows'; + +export type JourneyRow = EntityDTO; +export type JourneyEntryRow = EntityDTO; + +/** + * An active journey owned by `userId`, with the owner already on its + * contributor list as the journeys service adds them. Journey timestamps are + * epoch milliseconds, not DATETIME text. + */ +export async function makeJourney( + orm: FactoryOrm, + userId: number, + overrides: EntityData = {}, +): Promise { + const now = Date.now(); + const journey = await createRow(orm, Journeys, { + user: userId, + title: `Test Journey ${nextSeq('journey')}`, + subtitle: null, + status: 'active', + created_at: now, + updated_at: now, + ...overrides, + }); + await insertRow(orm, JourneyContributors, { journey: journey.id, user: userId, role: 'owner', added_at: now }); + return journey; +} + +/** A private entry in the journey, dated 2026-01-15 unless told otherwise. */ +export function makeJourneyEntry( + orm: FactoryOrm, + journeyId: number, + authorId: number, + overrides: EntityData = {}, +): Promise { + const now = Date.now(); + return createRow(orm, JourneyEntries, { + journey: journeyId, + author: authorId, + type: 'entry', + entry_date: '2026-01-15', + visibility: 'private', + sort_order: 0, + created_at: now, + updated_at: now, + ...overrides, + }); +} + +/** Adds the user as a contributor; an existing contributor keeps their role. */ +export async function addJourneyContributor( + orm: FactoryOrm, + journeyId: number, + userId: number, + role: 'editor' | 'viewer' = 'editor', +): Promise { + await insertRowIgnoringConflict(orm, JourneyContributors, { journey: journeyId, user: userId, role, added_at: Date.now() }); +} + +/** Links the trip to the journey; linking it twice keeps the first link. */ +export async function linkTripToJourney(orm: FactoryOrm, journeyId: number, tripId: number): Promise { + await insertRowIgnoringConflict(orm, JourneyTrips, { journey: journeyId, trip: tripId, added_at: Date.now() }); +} diff --git a/server/tests/helpers/factories/notifications.ts b/server/tests/helpers/factories/notifications.ts new file mode 100644 index 000000000..339b349a0 --- /dev/null +++ b/server/tests/helpers/factories/notifications.ts @@ -0,0 +1,41 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity'; +import { Notifications } from '../../../src/db/entities/Notifications.entity'; +import type { FactoryOrm } from './context'; +import { createRow, upsertRow } from './rows'; +import { setAppSetting } from './settings'; + +export type NotificationRow = EntityDTO; + +/** An unread in-app notification of the simple kind for `recipientId`. */ +export function makeNotification( + orm: FactoryOrm, + recipientId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, Notifications, { + recipient: recipientId, + type: 'simple', + scope: 'user', + target: recipientId, + title_key: 'notifications.test.title', + text_key: 'notifications.test.text', + is_read: 0, + ...overrides, + }); +} + +/** Sets the instance's active notification channels ('email', 'webhook', 'email,webhook', 'none'). */ +export function setNotificationChannels(orm: FactoryOrm, channels: string): Promise { + return setAppSetting(orm, 'notification_channels', channels); +} + +/** Turns one event on one channel off for the user. */ +export async function disableNotificationPref( + orm: FactoryOrm, + userId: number, + eventType: string, + channel: string, +): Promise { + await upsertRow(orm, NotificationChannelPreferences, { user: userId, event_type: eventType, channel, enabled: 0 }); +} diff --git a/server/tests/helpers/factories/packing.ts b/server/tests/helpers/factories/packing.ts new file mode 100644 index 000000000..4966f7437 --- /dev/null +++ b/server/tests/helpers/factories/packing.ts @@ -0,0 +1,46 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { PackingBags } from '../../../src/db/entities/PackingBags.entity'; +import { PackingItems } from '../../../src/db/entities/PackingItems.entity'; +import { inContext, nextSeq, type FactoryOrm } from './context'; +import { createRow } from './rows'; + +export type PackingItemRow = EntityDTO; +export type PackingBagRow = EntityDTO; + +/** An unchecked item on the trip's packing list, under Clothing. */ +export function makePackingItem( + orm: FactoryOrm, + tripId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, PackingItems, { + trip: tripId, + name: 'Test Item', + category: 'Clothing', + checked: 0, + ...overrides, + }); +} + +/** A bag on the trip. */ +export function makePackingBag( + orm: FactoryOrm, + tripId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, PackingBags, { + trip: tripId, + name: `Bag ${nextSeq('packing-bag')}`, + ...overrides, + }); +} + +/** Shares the bag with the users; pairs that already exist are skipped. */ +export async function addPackingBagMembers(orm: FactoryOrm, bagId: number, userIds: number[]): Promise { + await inContext(orm, (em) => em.getRepository(PackingBags).insertMembersIgnore(bagId, userIds)); +} + +/** Marks the users as recipients of the item; pairs that already exist are skipped. */ +export async function addPackingItemRecipients(orm: FactoryOrm, itemId: number, userIds: number[]): Promise { + await inContext(orm, (em) => em.getRepository(PackingItems).insertRecipientsIgnore(itemId, userIds)); +} diff --git a/server/tests/helpers/factories/photos.ts b/server/tests/helpers/factories/photos.ts new file mode 100644 index 000000000..279bdc31c --- /dev/null +++ b/server/tests/helpers/factories/photos.ts @@ -0,0 +1,53 @@ +import type { EntityDTO } from '@mikro-orm/core'; +import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; +import { TripAlbumLinks } from '../../../src/db/entities/TripAlbumLinks.entity'; +import { TripPhotos } from '../../../src/db/entities/TripPhotos.entity'; +import type { FactoryOrm } from './context'; +import { createRow, findRow, insertRow } from './rows'; + +export type TripPhotoRow = EntityDTO; + +/** + * Registers the provider asset in the central photo table (once per + * provider, asset and owner) and puts it on the trip, private unless + * `shared` is set. + */ +export async function addTripPhoto( + orm: FactoryOrm, + tripId: number, + userId: number, + assetId: string, + provider: string, + opts: { shared?: boolean; albumLinkId?: number } = {}, +): Promise { + // Looked up first rather than upserted: the unique index on (provider, + // asset_id, owner_id) is partial, and SQLite only matches an ON CONFLICT + // target to a partial index that repeats its WHERE clause. + const known = await findRow(orm, TrekPhotos, { provider, asset_id: assetId, owner: userId }); + const photoId = known?.id ?? (await insertRow(orm, TrekPhotos, { provider, asset_id: assetId, owner: userId })); + return createRow(orm, TripPhotos, { + trip: tripId, + user: userId, + photo: photoId, + shared: opts.shared ? 1 : 0, + albumLink: opts.albumLinkId ?? null, + }); +} + +/** Links a provider album to the trip for the user. */ +export function addAlbumLink( + orm: FactoryOrm, + tripId: number, + userId: number, + provider: string, + albumId: string, + albumName = 'Test Album', +): Promise> { + return createRow(orm, TripAlbumLinks, { + trip: tripId, + user: userId, + provider, + album_id: albumId, + album_name: albumName, + }); +} diff --git a/server/tests/helpers/factories/places.ts b/server/tests/helpers/factories/places.ts new file mode 100644 index 000000000..a70b05427 --- /dev/null +++ b/server/tests/helpers/factories/places.ts @@ -0,0 +1,61 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { Categories } from '../../../src/db/entities/Categories.entity'; +import { Places } from '../../../src/db/entities/Places.entity'; +import { Tags } from '../../../src/db/entities/Tags.entity'; +import { inContext, nextSeq, type FactoryOrm } from './context'; +import { createRow } from './rows'; + +export type PlaceRow = EntityDTO; +export type CategoryRow = EntityDTO; +export type TagRow = EntityDTO; + +/** A category; global unless `user` names an owner. */ +export function makeCategory(orm: FactoryOrm, overrides: EntityData = {}): Promise { + return createRow(orm, Categories, { + name: `Test Category ${nextSeq('category')}`, + color: '#6366f1', + icon: '📍', + ...overrides, + }); +} + +/** A tag owned by `userId`. */ +export function makeTag(orm: FactoryOrm, userId: number, overrides: EntityData = {}): Promise { + return createRow(orm, Tags, { + user: userId, + name: `Test Tag ${nextSeq('tag')}`, + color: '#10b981', + ...overrides, + }); +} + +/** The lowest-id category, the one a place gets when the test names none. */ +function firstCategoryId(orm: FactoryOrm): Promise { + return inContext(orm, async (em) => { + const first = await em.findOne(Categories, {}, { orderBy: { id: 'asc' }, disableIdentityMap: true }); + return first?.id ?? null; + }); +} + +/** + * A place on the trip, in Paris unless told otherwise, filed under the first + * seeded category when `category` is not given (pass `category: null` for + * an uncategorised one). + */ +export async function makePlace(orm: FactoryOrm, tripId: number, overrides: EntityData = {}): Promise { + const category = 'category' in overrides ? overrides.category : await firstCategoryId(orm); + return createRow(orm, Places, { + trip: tripId, + name: 'Test Place', + lat: 48.8566, + lng: 2.3522, + description: null, + ...overrides, + category, + }); +} + +/** Puts the tags on the place; tags it already has are skipped. */ +export async function tagPlace(orm: FactoryOrm, placeId: number, tagIds: number[]): Promise { + await inContext(orm, (em) => em.getRepository(Tags).insertIgnore(placeId, tagIds)); +} diff --git a/server/tests/helpers/factories/plugins.ts b/server/tests/helpers/factories/plugins.ts new file mode 100644 index 000000000..cd35a478a --- /dev/null +++ b/server/tests/helpers/factories/plugins.ts @@ -0,0 +1,46 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { PluginSettingsFields } from '../../../src/db/entities/PluginSettingsFields.entity'; +import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity'; +import { Plugins } from '../../../src/db/entities/Plugins.entity'; +import { dbNow } from '../../../src/db/types/db-timestamp.type'; +import type { FactoryOrm } from './context'; +import { createRow, findRow, insertRow, upsertRow } from './rows'; + +export type PluginRow = EntityDTO; + +/** + * An installed plugin row, inactive and disabled unless told otherwise. Only + * the row: no child process is spawned, which is what a test of the admin or + * settings surface wants. + */ +export async function makePlugin(orm: FactoryOrm, id: string, overrides: EntityData = {}): Promise { + await insertRow(orm, Plugins, { id, name: id, version: '1.0.0', ...overrides }); + const plugin = await findRow(orm, Plugins, { id }); + if (!plugin) throw new Error(`makePlugin: no plugin ${id} after its insert`); + return plugin; +} + +/** One settings field the plugin declares: instance-scoped text unless told otherwise. */ +export function makePluginSettingsField( + orm: FactoryOrm, + pluginId: string, + fieldKey: string, + overrides: EntityData = {}, +): Promise> { + return createRow(orm, PluginSettingsFields, { plugin_id: pluginId, field_key: fieldKey, label: fieldKey, ...overrides }); +} + +/** Stores the user's config for the plugin as JSON, replacing any earlier one. */ +export async function setPluginUserConfig( + orm: FactoryOrm, + pluginId: string, + userId: number, + config: Record, +): Promise { + await upsertRow(orm, PluginUserConfig, { + plugin_id: pluginId, + user_id: userId, + config: JSON.stringify(config), + updated_at: dbNow(), + }); +} diff --git a/server/tests/helpers/factories/reservations.ts b/server/tests/helpers/factories/reservations.ts new file mode 100644 index 000000000..d3757e7f2 --- /dev/null +++ b/server/tests/helpers/factories/reservations.ts @@ -0,0 +1,54 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { ReservationEndpoints } from '../../../src/db/entities/ReservationEndpoints.entity'; +import { ReservationTravelers } from '../../../src/db/entities/ReservationTravelers.entity'; +import { Reservations } from '../../../src/db/entities/Reservations.entity'; +import type { FactoryOrm } from './context'; +import { createRow } from './rows'; + +export type ReservationRow = EntityDTO; +export type ReservationEndpointRow = EntityDTO; + +/** A booking on the trip: a flight titled "Test Reservation" unless told otherwise. */ +export function makeReservation( + orm: FactoryOrm, + tripId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, Reservations, { + trip: tripId, + title: 'Test Reservation', + type: 'flight', + day: null, + ...overrides, + }); +} + +/** + * One end of a transport booking (`role` 'from' or 'to'), at Paris CDG + * unless told otherwise. + */ +export function makeReservationEndpoint( + orm: FactoryOrm, + reservationId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, ReservationEndpoints, { + reservation: reservationId, + role: 'from', + sequence: 0, + name: 'Paris Charles de Gaulle', + code: 'CDG', + lat: 49.0097, + lng: 2.5479, + ...overrides, + }); +} + +/** Puts the user on the booking's traveller list. */ +export function addReservationTraveler( + orm: FactoryOrm, + reservationId: number, + userId: number, +): Promise> { + return createRow(orm, ReservationTravelers, { reservation: reservationId, user: userId }); +} diff --git a/server/tests/helpers/factories/rows.ts b/server/tests/helpers/factories/rows.ts new file mode 100644 index 000000000..7c2e34b04 --- /dev/null +++ b/server/tests/helpers/factories/rows.ts @@ -0,0 +1,178 @@ +import type { + EntityClass, + EntityManager, + EntityData, + EntityDTO, + FilterQuery, + FindOptions, + Primary, + RequiredEntityData, + UpsertOptions, +} from '@mikro-orm/core'; +import { toRow } from '../../../src/db/repositories/_shared/rows'; +import { inContext, type FactoryOrm } from './context'; + +/** + * Typed row access for tests, through MikroORM instead of raw SQL. + * + * These are the generic building blocks the domain factories next to this + * file are made of, and the readers a test uses to check what a request left + * behind. Every helper runs in a request context of its own (`inContext`), so + * it works the same against the app's ORM and a `createTestOrm()` one. + * + * Writes are native statements (`em.insert`, `em.nativeUpdate`, + * `em.nativeDelete`), the same calls the repositories build on: one INSERT + * with exactly the columns given, so a column left out takes its database + * default, as it did in the raw `INSERT` these replace. Reads skip the + * identity map and come back as plain rows (`toRow`), the shape a + * `SELECT *` returned. + */ + +/** Inserts one row and returns its primary key (the autoincrement id for most tables). */ +export function insertRow( + orm: FactoryOrm, + entity: EntityClass, + data: RequiredEntityData, +): Promise> { + return inContext(orm, (em) => { + assertWritable(em, entity, data); + return em.insert(entity, data); + }); +} + +/** Inserts several rows in one statement and returns their primary keys in order. */ +export function insertRows( + orm: FactoryOrm, + entity: EntityClass, + data: RequiredEntityData[], +): Promise[]> { + if (data.length === 0) return Promise.resolve([]); + return inContext(orm, (em) => { + for (const row of data) assertWritable(em, entity, row); + return em.insertMany(entity, data); + }); +} + +/** + * Inserts one row into a table keyed by a single autoincrement `id` and reads + * it back, defaults and all. For a table with a composite or natural key use + * `insertRow` and then `findRow` with the key columns. + */ +export async function createRow( + orm: FactoryOrm, + entity: EntityClass, + data: RequiredEntityData, +): Promise> { + const id = await insertRow(orm, entity, data); + const row = await findRow(orm, entity, { id } as FilterQuery); + if (!row) throw new Error(`createRow: ${entity.name} ${String(id)} is not there after its insert`); + return row; +} + +/** The first row matching `where`, or null. */ +export function findRow( + orm: FactoryOrm, + entity: EntityClass, + where: FilterQuery, +): Promise | null> { + return inContext(orm, async (em) => { + const found = await em.findOne(entity, where, { disableIdentityMap: true }); + return found ? toRow(found) : null; + }); +} + +/** Every row matching `where` (all rows for `{}`), optionally ordered. */ +export function findRows( + orm: FactoryOrm, + entity: EntityClass, + where: FilterQuery = {} as FilterQuery, + orderBy?: FindOptions['orderBy'], +): Promise[]> { + return inContext(orm, async (em) => { + const found = await em.find(entity, where, { disableIdentityMap: true, orderBy }); + return found.map((row) => toRow(row)); + }); +} + +/** How many rows match `where` (all rows for `{}`). */ +export function countRows( + orm: FactoryOrm, + entity: EntityClass, + where: FilterQuery = {} as FilterQuery, +): Promise { + return inContext(orm, (em) => em.count(entity, where)); +} + +/** Writes `data` to every row matching `where` and returns how many it touched. */ +export function updateRows( + orm: FactoryOrm, + entity: EntityClass, + where: FilterQuery, + data: EntityData, +): Promise { + return inContext(orm, (em) => { + assertWritable(em, entity, data); + return em.nativeUpdate(entity, where, data); + }); +} + +/** Deletes every row matching `where` (all rows for `{}`) and returns how many it removed. */ +export function deleteRows( + orm: FactoryOrm, + entity: EntityClass, + where: FilterQuery = {} as FilterQuery, +): Promise { + return inContext(orm, (em) => em.nativeDelete(entity, where)); +} + +/** + * Inserts the row, or overwrites the given columns when its key already + * exists: the `INSERT OR REPLACE` / `ON CONFLICT DO UPDATE` of the raw + * fixtures, for tables keyed by a natural key (`app_settings.key`, the + * `notification_channel_preferences` triple, an addon id). `mergeFields` + * narrows what an existing row gets overwritten with, like an + * `ON CONFLICT ... DO UPDATE SET enabled = excluded.enabled`. + */ +export function upsertRow( + orm: FactoryOrm, + entity: EntityClass, + data: EntityData, + mergeFields?: UpsertOptions['onConflictMergeFields'], +): Promise { + return inContext(orm, async (em) => { + assertWritable(em, entity, data); + await em.upsert(entity, data, mergeFields ? { onConflictMergeFields: mergeFields } : undefined); + }); +} + +/** Inserts the row unless its key already exists: the raw fixtures' `INSERT OR IGNORE`. */ +export function insertRowIgnoringConflict( + orm: FactoryOrm, + entity: EntityClass, + data: EntityData, +): Promise { + return inContext(orm, async (em) => { + assertWritable(em, entity, data); + await em.upsert(entity, data, { onConflictAction: 'ignore' }); + }); +} + +/** + * Refuses a column the entity maps read-only. Most foreign keys appear twice + * on an entity: the relation (`trip`), which is written, and its `trip_id` + * twin, which is only read (`persist(false)`). MikroORM drops a value given + * for the twin without a word, so a fixture written as `{ trip_id: 3 }` + * would insert a row with no trip at all; this names the relation instead. + */ +export function assertWritable(em: EntityManager, entity: EntityClass, data: object): void { + const meta = em.getMetadata().get(entity); + for (const key of Object.keys(data)) { + const prop = meta.props.find((p) => p.name === key); + if (!prop || prop.persist !== false) continue; + const column = prop.fieldNames?.[0]; + const owner = meta.props.find((p) => p !== prop && p.persist !== false && column && p.fieldNames?.includes(column)); + throw new Error( + `${entity.name}.${key} is read-only in the entity` + (owner ? `; write it through '${owner.name}'` : ''), + ); + } +} diff --git a/server/tests/helpers/factories/settings.ts b/server/tests/helpers/factories/settings.ts new file mode 100644 index 000000000..bb57abfe8 --- /dev/null +++ b/server/tests/helpers/factories/settings.ts @@ -0,0 +1,43 @@ +import { Addons } from '../../../src/db/entities/Addons.entity'; +import { AppSettings } from '../../../src/db/entities/AppSettings.entity'; +import { Settings } from '../../../src/db/entities/Settings.entity'; +import type { FactoryOrm } from './context'; +import { findRow, upsertRow } from './rows'; + +/** Writes an instance-wide setting (`app_settings`), replacing any earlier value. */ +export async function setAppSetting(orm: FactoryOrm, key: string, value: string): Promise { + await upsertRow(orm, AppSettings, { key, value }); +} + +/** The instance-wide setting's value, or null when it is unset. */ +export async function readAppSetting(orm: FactoryOrm, key: string): Promise { + return (await findRow(orm, AppSettings, { key }))?.value ?? null; +} + +/** Writes one of the user's own settings (`settings`, unique per user and key), replacing any earlier value. */ +export async function setUserSetting(orm: FactoryOrm, userId: number, key: string, value: string): Promise { + await upsertRow(orm, Settings, { user: userId, key, value }); +} + +/** The user's setting, or null when it is unset. */ +export async function readUserSetting(orm: FactoryOrm, userId: number, key: string): Promise { + return (await findRow(orm, Settings, { user: userId, key }))?.value ?? null; +} + +/** + * Flips an addon the way the admin panel does. An addon the instance does not + * know yet is created as a global one named after its id; an existing one + * keeps its name, type and order and only changes `enabled`. + */ +export async function setAddonEnabled(orm: FactoryOrm, addonId: string, enabled: boolean): Promise { + await upsertRow(orm, Addons, { id: addonId, name: addonId, type: 'global', enabled }, ['enabled']); +} + +/** Collab's chat, notes, polls and next-up panels are opt-out app settings, not addon rows. */ +export async function setCollabFeature( + orm: FactoryOrm, + feature: 'chat' | 'notes' | 'polls' | 'whatsnext', + enabled: boolean, +): Promise { + await setAppSetting(orm, `collab_${feature}_enabled`, enabled ? 'true' : 'false'); +} diff --git a/server/tests/helpers/factories/todos.ts b/server/tests/helpers/factories/todos.ts new file mode 100644 index 000000000..51589d6b0 --- /dev/null +++ b/server/tests/helpers/factories/todos.ts @@ -0,0 +1,32 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { TodoItems } from '../../../src/db/entities/TodoItems.entity'; +import { inContext, type FactoryOrm } from './context'; +import { createRow } from './rows'; + +export type TodoItemRow = EntityDTO; + +/** An open to-do on the trip, appended after the trip's last one. */ +export async function makeTodoItem( + orm: FactoryOrm, + tripId: number, + overrides: EntityData = {}, +): Promise { + const sortOrder = + overrides.sort_order ?? + (await inContext(orm, async (em) => { + const last = await em.findOne( + TodoItems, + { trip: tripId }, + { orderBy: { sort_order: 'desc' }, disableIdentityMap: true }, + ); + return last?.sort_order == null ? 0 : last.sort_order + 1; + })); + return createRow(orm, TodoItems, { + trip: tripId, + name: 'Test Todo', + checked: 0, + category: null, + ...overrides, + sort_order: sortOrder, + }); +} diff --git a/server/tests/helpers/factories/tokens.ts b/server/tests/helpers/factories/tokens.ts new file mode 100644 index 000000000..d9c1bc9e6 --- /dev/null +++ b/server/tests/helpers/factories/tokens.ts @@ -0,0 +1,74 @@ +import { createHash, randomBytes } from 'node:crypto'; +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { InviteTokens } from '../../../src/db/entities/InviteTokens.entity'; +import { McpTokens } from '../../../src/db/entities/McpTokens.entity'; +import { OauthClients } from '../../../src/db/entities/OauthClients.entity'; +import { nextSeq, type FactoryOrm } from './context'; +import { createRow, findRow, insertRow } from './rows'; + +const sha256 = (value: string): string => createHash('sha256').update(value).digest('hex'); + +/** + * An MCP (or API) token for the user. Only its SHA-256 is stored, as the + * tokens service stores it; the raw token comes back so the test can send it. + */ +export async function makeMcpToken( + orm: FactoryOrm, + userId: number, + overrides: EntityData & { rawToken?: string } = {}, +): Promise<{ token: EntityDTO; rawToken: string }> { + const { rawToken: given, ...columns } = overrides; + const rawToken = given ?? `trek_test_${nextSeq('mcp-token')}_${randomBytes(8).toString('hex')}`; + const token = await createRow(orm, McpTokens, { + user: userId, + name: 'Test Token', + token_hash: sha256(rawToken), + token_prefix: rawToken.slice(0, 12), + ...columns, + }); + return { token, rawToken }; +} + +/** + * An OAuth client registered by the user (or by nobody, for `null`), + * confidential unless told otherwise. Only the secret's SHA-256 is stored; + * the raw secret comes back. + */ +export async function makeOauthClient( + orm: FactoryOrm, + userId: number | null, + overrides: Omit, 'id'> & { id?: string; secret?: string } = {}, +): Promise<{ client: EntityDTO; secret: string }> { + const { secret: given, id: givenId, ...columns } = overrides; + const n = nextSeq('oauth-client'); + const secret = given ?? `test-secret-${n}-${randomBytes(8).toString('hex')}`; + const id = givenId ?? `test-oauth-client-${n}`; + await insertRow(orm, OauthClients, { + id, + user: userId, + name: `Test Client ${n}`, + client_id: `client-${n}`, + client_secret_hash: sha256(secret), + redirect_uris: JSON.stringify(['https://client.example.test/callback']), + allowed_scopes: JSON.stringify(['trips:read']), + ...columns, + }); + const client = await findRow(orm, OauthClients, { id }); + if (!client) throw new Error(`makeOauthClient: no client ${id} after its insert`); + return { client, secret }; +} + +/** A one-use registration invite created by `createdBy`. */ +export function makeInviteToken( + orm: FactoryOrm, + createdBy: number, + overrides: EntityData = {}, +): Promise> { + return createRow(orm, InviteTokens, { + token: `test-invite-${nextSeq('invite-token')}`, + max_uses: 1, + used_count: 0, + createdByRef: createdBy, + ...overrides, + }); +} diff --git a/server/tests/helpers/factories/tours.ts b/server/tests/helpers/factories/tours.ts new file mode 100644 index 000000000..710bcc5c3 --- /dev/null +++ b/server/tests/helpers/factories/tours.ts @@ -0,0 +1,37 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; +import { Tours } from '../../../src/db/entities/Tours.entity'; +import type { FactoryOrm } from './context'; +import { findRow, insertRow, insertRows } from './rows'; + +export type TourRow = EntityDTO; + +/** + * Turns the place into a tour. A tour is keyed by its place, so it has no id + * of its own; its type defaults to 'hike', the one the tours migration seeds. + */ +export async function makeTour(orm: FactoryOrm, placeId: number, overrides: EntityData = {}): Promise { + await insertRow(orm, Tours, { place: placeId, tourTypeRef: 'hike', ...overrides }); + const tour = await findRow(orm, Tours, { place: placeId }); + if (!tour) throw new Error(`makeTour: no tour for place ${placeId} after its insert`); + return tour; +} + +type WaypointRole = 'start' | 'via' | 'end'; + +/** + * The tour's waypoints, in the order given and numbered from 0. Without a + * role the first is the start, the last the end and the rest are vias. + */ +export async function addTourWaypoints( + orm: FactoryOrm, + placeId: number, + points: Array<{ lat: number; lng: number; role?: WaypointRole }>, +): Promise { + const roleAt = (i: number): WaypointRole => (i === 0 ? 'start' : i === points.length - 1 ? 'end' : 'via'); + await insertRows( + orm, + TourWaypoints, + points.map((p, sequence) => ({ place: placeId, lat: p.lat, lng: p.lng, role: p.role ?? roleAt(sequence), sequence })), + ); +} diff --git a/server/tests/helpers/factories/trips.ts b/server/tests/helpers/factories/trips.ts new file mode 100644 index 000000000..92c2880bd --- /dev/null +++ b/server/tests/helpers/factories/trips.ts @@ -0,0 +1,131 @@ +import type { EntityDTO } from '@mikro-orm/core'; +import { Days } from '../../../src/db/entities/Days.entity'; +import { ShareTokens } from '../../../src/db/entities/ShareTokens.entity'; +import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; +import { inContext, nextSeq, type FactoryOrm } from './context'; +import { createRow, findRows, insertRows } from './rows'; + +export type TripRow = EntityDTO; +export type DayRow = EntityDTO; + +export interface TripOverrides { + title?: string; + description?: string | null; + start_date?: string | null; + end_date?: string | null; + currency?: string; + feed_token?: string | null; + is_archived?: number; + reminder_days?: number; + cover_image?: string | null; +} + +/** + * The calendar dates from `start` to `end`, both included. Stepped in UTC: a + * date-only string parses as UTC midnight, and stepping local time would + * drop or repeat a day across a DST change. + */ +export function datesBetween(start: string, end: string): string[] { + const dates: string[] = []; + for (let d = new Date(start); d <= new Date(end); d.setUTCDate(d.getUTCDate() + 1)) { + dates.push(d.toISOString().slice(0, 10)); + } + return dates; +} + +/** + * A trip owned by `userId`. With both dates set it also gets one day per + * date, numbered from 1, as the trips service creates them. + */ +export async function makeTrip(orm: FactoryOrm, userId: number, overrides: TripOverrides = {}): Promise { + const trip = await createRow(orm, Trips, { + user: userId, + title: overrides.title ?? `Test Trip ${nextSeq('trip')}`, + description: overrides.description ?? null, + start_date: overrides.start_date ?? null, + end_date: overrides.end_date ?? null, + ...(overrides.currency !== undefined ? { currency: overrides.currency } : {}), + ...(overrides.feed_token !== undefined ? { feed_token: overrides.feed_token } : {}), + ...(overrides.is_archived !== undefined ? { is_archived: overrides.is_archived } : {}), + ...(overrides.reminder_days !== undefined ? { reminder_days: overrides.reminder_days } : {}), + ...(overrides.cover_image !== undefined ? { cover_image: overrides.cover_image } : {}), + }); + if (overrides.start_date && overrides.end_date) { + const dates = datesBetween(overrides.start_date, overrides.end_date); + await insertRows( + orm, + Days, + dates.map((date, i) => ({ trip: trip.id, day_number: i + 1, date })), + ); + } + return trip; +} + +/** One more day on the trip; `day_number` defaults to the next free one. */ +export async function makeDay( + orm: FactoryOrm, + tripId: number, + overrides: { day_number?: number; date?: string | null; title?: string | null; notes?: string | null } = {}, +): Promise { + const dayNumber = + overrides.day_number ?? + (await inContext(orm, async (em) => { + const last = await em.findOne( + Days, + { trip: tripId }, + { orderBy: { day_number: 'desc' }, disableIdentityMap: true }, + ); + return (last?.day_number ?? 0) + 1; + })); + return createRow(orm, Days, { + trip: tripId, + day_number: dayNumber, + date: overrides.date ?? null, + title: overrides.title ?? null, + notes: overrides.notes ?? null, + }); +} + +/** The trip's days in day order. */ +export function readTripDays(orm: FactoryOrm, tripId: number): Promise { + return findRows(orm, Days, { trip: tripId }, { day_number: 'asc' }); +} + +/** Adds `userId` to the trip's members; a second call for the same pair is a no-op. */ +export async function addTripMember( + orm: FactoryOrm, + tripId: number, + userId: number, + invitedBy: number | null = null, +): Promise { + await inContext(orm, (em) => em.getRepository(TripMembers).addIgnoringConflict(tripId, userId, invitedBy)); +} + +/** A public share link for the trip, sharing what the overrides switch on. */ +export function makeShareToken( + orm: FactoryOrm, + tripId: number, + createdBy: number, + overrides: Partial< + Pick< + EntityDTO, + | 'token' + | 'share_map' + | 'share_bookings' + | 'share_packing' + | 'share_budget' + | 'share_collab' + | 'share_travel_only' + | 'share_hide_images' + | 'expires_at' + > + > = {}, +): Promise> { + return createRow(orm, ShareTokens, { + trip: tripId, + createdByRef: createdBy, + token: overrides.token ?? `share-${nextSeq('share-token')}-${tripId}`, + ...overrides, + }); +} diff --git a/server/tests/helpers/factories/users.ts b/server/tests/helpers/factories/users.ts new file mode 100644 index 000000000..55057bf14 --- /dev/null +++ b/server/tests/helpers/factories/users.ts @@ -0,0 +1,95 @@ +import type { EntityDTO } from '@mikro-orm/core'; +import bcrypt from 'bcryptjs'; +import { Users } from '../../../src/db/entities/Users.entity'; +import { encrypt_api_key } from '../../../src/nest/common/crypto/apiKeyCrypto'; +import { encryptMfaSecret } from '../../../src/nest/common/crypto/mfaCrypto'; +import { nextSeq, type FactoryOrm } from './context'; +import { createRow, findRow, updateRows } from './rows'; + +export type UserRow = EntityDTO; + +export interface UserOverrides { + /** Pin the id, for suites whose session cookie is signed for a fixed user. */ + id?: number; + username?: string; + email?: string; + password?: string; + role?: 'admin' | 'user'; + password_version?: number; + is_guest?: number; + display_name?: string | null; + must_change_password?: number; +} + +/** + * A user with a known password, stored as a bcrypt hash at cost 4 so tests + * stay fast. Defaults are unique per worker: `testuser`, + * `user@test.example.com`, `TestPass!`. + */ +export async function makeUser( + orm: FactoryOrm, + overrides: UserOverrides = {}, +): Promise<{ user: UserRow; password: string }> { + const n = nextSeq('user'); + const password = overrides.password ?? `TestPass${n}!`; + const user = await createRow(orm, Users, { + ...(overrides.id !== undefined ? { id: overrides.id } : {}), + username: overrides.username ?? `testuser${n}`, + email: overrides.email ?? `user${n}@test.example.com`, + password_hash: bcrypt.hashSync(password, 4), + role: overrides.role ?? 'user', + password_version: overrides.password_version ?? 0, + is_guest: overrides.is_guest ?? 0, + display_name: overrides.display_name ?? null, + must_change_password: overrides.must_change_password ?? 0, + }); + return { user, password }; +} + +export function makeAdmin( + orm: FactoryOrm, + overrides: Omit = {}, +): Promise<{ user: UserRow; password: string }> { + return makeUser(orm, { ...overrides, role: 'admin' }); +} + +/** The fixed base32 TOTP secret `makeUserWithMfa` enables, so a test can compute valid codes. */ +export const KNOWN_MFA_SECRET = 'JBSWY3DPEHPK3PXP'; + +/** A user with MFA already on, written directly so no rate-limited endpoint is involved. */ +export async function makeUserWithMfa( + orm: FactoryOrm, + overrides: UserOverrides = {}, +): Promise<{ user: UserRow; password: string; totpSecret: string }> { + const { user, password } = await makeUser(orm, overrides); + await updateRows(orm, Users, { id: user.id }, { mfa_enabled: 1, mfa_secret: encryptMfaSecret(KNOWN_MFA_SECRET) }); + return { user: await readUser(orm, user.id), password, totpSecret: KNOWN_MFA_SECRET }; +} + +/** The user row as it is now; fails the test when it is gone. */ +export async function readUser(orm: FactoryOrm, id: number): Promise { + const row = await findRow(orm, Users, { id }); + if (!row) throw new Error(`readUser: no user ${id}`); + return row; +} + +/** Immich credentials, with the API key encrypted the way the settings route stores it. */ +export async function setImmichCredentials(orm: FactoryOrm, userId: number, url: string, apiKey: string): Promise { + await updateRows(orm, Users, { id: userId }, { immich_url: url, immich_api_key: encrypt_api_key(apiKey) }); +} + +/** Synology credentials, with the password encrypted the way the settings route stores it. */ +export async function setSynologyCredentials( + orm: FactoryOrm, + userId: number, + url: string, + username: string, + password: string, +): Promise { + await updateRows( + orm, + Users, + { id: userId }, + { synology_url: url, synology_username: username, synology_password: encrypt_api_key(password) }, + ); +} diff --git a/server/tests/helpers/factories/vacay.ts b/server/tests/helpers/factories/vacay.ts new file mode 100644 index 000000000..bc901fdeb --- /dev/null +++ b/server/tests/helpers/factories/vacay.ts @@ -0,0 +1,38 @@ +import type { EntityData, EntityDTO } from '@mikro-orm/core'; +import { VacayEntries } from '../../../src/db/entities/VacayEntries.entity'; +import { VacayPlanMembers } from '../../../src/db/entities/VacayPlanMembers.entity'; +import { VacayPlans } from '../../../src/db/entities/VacayPlans.entity'; +import type { FactoryOrm } from './context'; +import { createRow } from './rows'; + +export type VacayPlanRow = EntityDTO; + +/** A vacation plan owned by `ownerId`, with the plan defaults the migration sets. */ +export function makeVacayPlan( + orm: FactoryOrm, + ownerId: number, + overrides: EntityData = {}, +): Promise { + return createRow(orm, VacayPlans, { owner: ownerId, ...overrides }); +} + +/** Invites the user into the plan; `status: 'accepted'` (the default here) makes them a member. */ +export function addVacayPlanMember( + orm: FactoryOrm, + planId: number, + userId: number, + status: 'pending' | 'accepted' = 'accepted', +): Promise> { + return createRow(orm, VacayPlanMembers, { plan: planId, user: userId, status }); +} + +/** A full vacation day for the user in the plan. */ +export function makeVacayEntry( + orm: FactoryOrm, + planId: number, + userId: number, + date: string, + overrides: EntityData = {}, +): Promise> { + return createRow(orm, VacayEntries, { plan: planId, user: userId, date, ...overrides }); +} diff --git a/server/tests/helpers/test-db.ts b/server/tests/helpers/test-db.ts index 1ff5b7163..50739b36c 100644 --- a/server/tests/helpers/test-db.ts +++ b/server/tests/helpers/test-db.ts @@ -153,6 +153,7 @@ export function resetTestDb(db: Database.Database): void { // (collections, oauth, plugins, settlements, …), every one of which leaked // its rows from one test into the next. Deletion order does not matter: // foreign_keys is OFF for the duration. + // test-sql-allow: the table list comes from sqlite_master, which no entity or repository maps. const tables = (db.prepare("SELECT name FROM sqlite_master WHERE type='table'").all() as { name: string }[]) .map(r => r.name) // sqlite_* are SQLite's own (sqlite_sequence, sqlite_stat1) — never ours. diff --git a/server/tests/integration/categories.test.ts b/server/tests/integration/categories.test.ts index 328ebeeca..e952850bc 100644 --- a/server/tests/integration/categories.test.ts +++ b/server/tests/integration/categories.test.ts @@ -22,20 +22,34 @@ vi.mock('../../src/config', () => ({ })); vi.mock('../../src/websocket', () => ({ broadcast: vi.fn(), broadcastToUser: vi.fn() })); +import { MikroORM } from '@mikro-orm/core'; import { db as testDb } from '../../src/db/database'; import { buildApp } from '../../src/bootstrap'; +import { Categories } from '../../src/db/entities/Categories.entity'; import { resetTestDb, resetRateLimits } from '../helpers/test-db'; -import { createUser, createAdmin } from '../helpers/factories'; +import { makeUser, makeAdmin } from '../helpers/factories/users'; +import { findRow } from '../helpers/factories/rows'; +import type { FactoryOrm } from '../helpers/factories/context'; import { authCookie } from '../helpers/auth'; let nestApp: INestApplication; let app: Application; +// The app's own ORM: the factories seed and read through it, never through raw SQL. +let orm: FactoryOrm; beforeAll(async () => { nestApp = await buildApp(); app = nestApp.getHttpAdapter().getInstance(); + orm = nestApp.get(MikroORM); }); +/** A seeded default category, for the cases that only need some existing id. */ +async function someCategoryId(): Promise { + const cat = await findRow(orm, Categories, {}); + if (!cat) throw new Error('the reset should have seeded the default categories'); + return cat.id; +} + beforeEach(async () => { resetTestDb(testDb); await resetRateLimits(nestApp); @@ -48,7 +62,7 @@ afterAll(async () => { describe('Categories', () => { it('CAT-001: GET /api/categories returns seeded default categories', async () => { - const { user } = createUser(testDb); + const { user } = await makeUser(orm); const res = await request(app) .get('/api/categories') .set('Cookie', authCookie(user.id)); @@ -60,7 +74,7 @@ describe('Categories', () => { }); it('CAT-002: POST /api/categories - admin creates a new category', async () => { - const { user: admin } = createAdmin(testDb); + const { user: admin } = await makeAdmin(orm); const res = await request(app) .post('/api/categories') .set('Cookie', authCookie(admin.id)) @@ -71,7 +85,7 @@ describe('Categories', () => { }); it('CAT-003: POST /api/categories - non-admin returns 403', async () => { - const { user } = createUser(testDb); + const { user } = await makeUser(orm); const res = await request(app) .post('/api/categories') .set('Cookie', authCookie(user.id)) @@ -80,7 +94,7 @@ describe('Categories', () => { }); it('CAT-004: POST /api/categories - missing name returns 400', async () => { - const { user: admin } = createAdmin(testDb); + const { user: admin } = await makeAdmin(orm); const res = await request(app) .post('/api/categories') .set('Cookie', authCookie(admin.id)) @@ -90,7 +104,7 @@ describe('Categories', () => { }); it('CAT-005: PUT /api/categories/:id - admin updates a category', async () => { - const { user: admin } = createAdmin(testDb); + const { user: admin } = await makeAdmin(orm); // First create one const createRes = await request(app) .post('/api/categories') @@ -110,18 +124,17 @@ describe('Categories', () => { }); it('CAT-006: PUT /api/categories/:id - non-admin returns 403', async () => { - const { user } = createUser(testDb); - // Get a seeded category id - const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number }; + const { user } = await makeUser(orm); + const catId = await someCategoryId(); const res = await request(app) - .put(`/api/categories/${cat.id}`) + .put(`/api/categories/${catId}`) .set('Cookie', authCookie(user.id)) .send({ name: 'Hacked' }); expect(res.status).toBe(403); }); it('CAT-007: PUT /api/categories/:id - non-existent category returns 404', async () => { - const { user: admin } = createAdmin(testDb); + const { user: admin } = await makeAdmin(orm); const res = await request(app) .put('/api/categories/99999') .set('Cookie', authCookie(admin.id)) @@ -130,7 +143,7 @@ describe('Categories', () => { }); it('CAT-008: DELETE /api/categories/:id - admin deletes a category', async () => { - const { user: admin } = createAdmin(testDb); + const { user: admin } = await makeAdmin(orm); const createRes = await request(app) .post('/api/categories') .set('Cookie', authCookie(admin.id)) @@ -144,15 +157,14 @@ describe('Categories', () => { expect(res.body.success).toBe(true); // Verify it's gone - const gone = testDb.prepare('SELECT id FROM categories WHERE id = ?').get(catId); - expect(gone).toBeUndefined(); + expect(await findRow(orm, Categories, { id: catId })).toBeNull(); }); it('CAT-009: DELETE /api/categories/:id - non-admin returns 403', async () => { - const { user } = createUser(testDb); - const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number }; + const { user } = await makeUser(orm); + const catId = await someCategoryId(); const res = await request(app) - .delete(`/api/categories/${cat.id}`) + .delete(`/api/categories/${catId}`) .set('Cookie', authCookie(user.id)); expect(res.status).toBe(403); }); @@ -163,7 +175,7 @@ describe('Categories', () => { }); it('CAT-011: PUT /api/categories/abc - non-numeric id returns the legacy 404, not a 500 (Plan 3b Task 2 fix round, item 1b)', async () => { - const { user: admin } = createAdmin(testDb); + const { user: admin } = await makeAdmin(orm); const res = await request(app) .put('/api/categories/abc') .set('Cookie', authCookie(admin.id)) @@ -173,7 +185,7 @@ describe('Categories', () => { }); it('CAT-012: DELETE /api/categories/abc - non-numeric id returns the legacy 404, not a 500 (Plan 3b Task 2 fix round, item 1b)', async () => { - const { user: admin } = createAdmin(testDb); + const { user: admin } = await makeAdmin(orm); const res = await request(app) .delete('/api/categories/abc') .set('Cookie', authCookie(admin.id)); diff --git a/server/tests/unit/helpers/orm-factories.test.ts b/server/tests/unit/helpers/orm-factories.test.ts new file mode 100644 index 000000000..2719fbfbd --- /dev/null +++ b/server/tests/unit/helpers/orm-factories.test.ts @@ -0,0 +1,335 @@ +/** + * The ORM test factories (tests/helpers/factories) replace the raw SQL + * fixtures the suite seeds with, so every suite that converts onto them + * trusts what they write. This pins that each one lands the row it promises, + * with its defaults and overrides, against the real migrated schema, and that + * the readers and the read-only-column guard behave as the README says. + */ +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; +import bcrypt from 'bcryptjs'; +import { Days } from '../../../src/db/entities/Days.entity'; +import { PackingBags } from '../../../src/db/entities/PackingBags.entity'; +import { Categories } from '../../../src/db/entities/Categories.entity'; +import { Tags } from '../../../src/db/entities/Tags.entity'; +import { TripMembers } from '../../../src/db/entities/TripMembers.entity'; +import { Trips } from '../../../src/db/entities/Trips.entity'; +import { Users } from '../../../src/db/entities/Users.entity'; +import { decryptMfaSecret } from '../../../src/nest/common/crypto/mfaCrypto'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestOrm, type TestOrm } from '../../helpers/test-orm'; +import { inContext } from '../../helpers/factories/context'; +import { + countRows, + createRow, + deleteRows, + findRow, + findRows, + insertRow, + insertRowIgnoringConflict, + updateRows, +} from '../../helpers/factories/rows'; +import { KNOWN_MFA_SECRET, makeAdmin, makeUser, makeUserWithMfa, readUser } from '../../helpers/factories/users'; +import { addTripMember, datesBetween, makeDay, makeShareToken, makeTrip, readTripDays } from '../../helpers/factories/trips'; +import { makeCategory, makePlace, makeTag, tagPlace } from '../../helpers/factories/places'; +import { makeDayAssignment, makeDayNote } from '../../helpers/factories/itinerary'; +import { makeReservation, makeReservationEndpoint } from '../../helpers/factories/reservations'; +import { addBudgetItemMember, makeBudgetItem } from '../../helpers/factories/budget'; +import { addPackingBagMembers, makePackingBag, makePackingItem } from '../../helpers/factories/packing'; +import { makeTodoItem } from '../../helpers/factories/todos'; +import { linkFile, makeTripFile } from '../../helpers/factories/files'; +import { addJourneyContributor, linkTripToJourney, makeJourney, makeJourneyEntry } from '../../helpers/factories/journeys'; +import { addTourWaypoints, makeTour } from '../../helpers/factories/tours'; +import { addCollectionMember, makeCollection, makeCollectionPlace } from '../../helpers/factories/collections'; +import { disableNotificationPref, makeNotification } from '../../helpers/factories/notifications'; +import { + readAppSetting, + readUserSetting, + setAddonEnabled, + setAppSetting, + setUserSetting, +} from '../../helpers/factories/settings'; +import { makePlugin, setPluginUserConfig } from '../../helpers/factories/plugins'; +import { makeInviteToken, makeMcpToken, makeOauthClient } from '../../helpers/factories/tokens'; +import { makeBucketListItem, markCountryVisited } from '../../helpers/factories/atlas'; +import { addVacayPlanMember, makeVacayEntry, makeVacayPlan } from '../../helpers/factories/vacay'; +import { addAlbumLink, addTripPhoto } from '../../helpers/factories/photos'; +import { makeCollabMessage, makeCollabNote } from '../../helpers/factories/collab'; +import { Addons } from '../../../src/db/entities/Addons.entity'; +import { JourneyContributors } from '../../../src/db/entities/JourneyContributors.entity'; +import { JourneyTrips } from '../../../src/db/entities/JourneyTrips.entity'; +import { NotificationChannelPreferences } from '../../../src/db/entities/NotificationChannelPreferences.entity'; +import { PluginUserConfig } from '../../../src/db/entities/PluginUserConfig.entity'; +import { TourWaypoints } from '../../../src/db/entities/TourWaypoints.entity'; +import { TrekPhotos } from '../../../src/db/entities/TrekPhotos.entity'; +import { VisitedCountries } from '../../../src/db/entities/VisitedCountries.entity'; + +const testDb = createSnapshotTestDb(); +let orm: TestOrm; + +beforeAll(async () => { + orm = await createTestOrm(testDb); +}); +beforeEach(() => { + resetTestDb(testDb); + orm.clear(); +}); +afterAll(async () => { + await orm.close(); + testDb.close(); +}); + +describe('ORM test factories: rows', () => { + it('ORMF-001: createRow reads the row back with the database defaults filled in', async () => { + const { user } = await makeUser(orm); + const trip = await createRow(orm, Trips, { user: user.id, title: 'Defaults' }); + expect(trip).toMatchObject({ user_id: user.id, title: 'Defaults', currency: 'EUR', is_archived: 0 }); + expect(typeof trip.id).toBe('number'); + }); + + it('ORMF-002: findRow, findRows, countRows, updateRows and deleteRows read and write what they say', async () => { + const { user } = await makeUser(orm); + await makeTrip(orm, user.id, { title: 'B' }); + await makeTrip(orm, user.id, { title: 'A' }); + + expect(await countRows(orm, Trips, { user: user.id })).toBe(2); + expect((await findRows(orm, Trips, { user: user.id }, { title: 'asc' })).map((t) => t.title)).toEqual(['A', 'B']); + expect(await updateRows(orm, Trips, { title: 'A' }, { title: 'C' })).toBe(1); + expect(await findRow(orm, Trips, { title: 'C' })).not.toBeNull(); + expect(await findRow(orm, Trips, { title: 'A' })).toBeNull(); + expect(await deleteRows(orm, Trips, { user: user.id })).toBe(2); + expect(await countRows(orm, Trips)).toBe(0); + }); + + it('ORMF-003: refuses a read-only foreign-key twin and names the relation to write instead', async () => { + const { user } = await makeUser(orm); + await expect(insertRow(orm, Trips, { user: user.id, title: 'x', user_id: user.id })).rejects.toThrow( + "Trips.user_id is read-only in the entity; write it through 'user'", + ); + await expect(updateRows(orm, Trips, { user: user.id }, { user_id: user.id })).rejects.toThrow('read-only'); + }); + + it('ORMF-004: insertRowIgnoringConflict leaves an existing row alone', async () => { + const { user } = await makeUser(orm); + await insertRowIgnoringConflict(orm, VisitedCountries, { user: user.id, country_code: 'FR', source: 'manual' }); + await insertRowIgnoringConflict(orm, VisitedCountries, { user: user.id, country_code: 'FR', source: 'trip' }); + expect(await findRows(orm, VisitedCountries, { user: user.id })).toEqual([ + expect.objectContaining({ country_code: 'FR', source: 'manual' }), + ]); + }); + + it('ORMF-005: every step runs in a request context of its own, so nothing lingers in an identity map', async () => { + const { user } = await makeUser(orm, { username: 'before' }); + await updateRows(orm, Users, { id: user.id }, { username: 'after' }); + expect((await readUser(orm, user.id)).username).toBe('after'); + await expect(inContext(orm, async (em) => em.getContext() !== orm.em)).resolves.toBe(true); + }); +}); + +describe('ORM test factories: users', () => { + it('ORMF-010: makeUser stores a bcrypt hash of the password it returns, with unique defaults', async () => { + const a = await makeUser(orm); + const b = await makeUser(orm); + expect(a.user.role).toBe('user'); + expect(a.user.email).not.toBe(b.user.email); + expect(a.user.username).not.toBe(b.user.username); + expect(bcrypt.compareSync(a.password, a.user.password_hash)).toBe(true); + }); + + it('ORMF-011: makeUser pins the id when asked, and makeAdmin makes an admin', async () => { + const { user } = await makeUser(orm, { id: 4242, email: 'pinned@example.test' }); + expect(user).toMatchObject({ id: 4242, email: 'pinned@example.test', password_version: 0 }); + expect((await makeAdmin(orm)).user.role).toBe('admin'); + }); + + it('ORMF-012: makeUserWithMfa turns MFA on with the known secret, encrypted', async () => { + const { user, totpSecret } = await makeUserWithMfa(orm); + expect(totpSecret).toBe(KNOWN_MFA_SECRET); + expect(user.mfa_enabled).toBe(1); + expect(user.mfa_secret).not.toBe(KNOWN_MFA_SECRET); + expect(decryptMfaSecret(user.mfa_secret as string)).toBe(KNOWN_MFA_SECRET); + }); +}); + +describe('ORM test factories: trips and plans', () => { + it('ORMF-020: makeTrip with both dates numbers one day per date, across a DST change too', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id, { start_date: '2026-03-28', end_date: '2026-03-30' }); + const days = await readTripDays(orm, trip.id); + expect(days.map((d) => [d.day_number, d.date])).toEqual([ + [1, '2026-03-28'], + [2, '2026-03-29'], + [3, '2026-03-30'], + ]); + expect(datesBetween('2026-10-24', '2026-10-26')).toEqual(['2026-10-24', '2026-10-25', '2026-10-26']); + }); + + it('ORMF-021: makeTrip without dates has no days; makeDay appends the next number', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id); + expect(await countRows(orm, Days, { trip: trip.id })).toBe(0); + expect((await makeDay(orm, trip.id)).day_number).toBe(1); + expect((await makeDay(orm, trip.id, { title: 'Second' })).day_number).toBe(2); + expect((await makeDay(orm, trip.id, { day_number: 7 })).day_number).toBe(7); + }); + + it('ORMF-022: addTripMember is idempotent and records who invited', async () => { + const { user: owner } = await makeUser(orm); + const { user: guest } = await makeUser(orm); + const trip = await makeTrip(orm, owner.id); + await addTripMember(orm, trip.id, guest.id, owner.id); + await addTripMember(orm, trip.id, guest.id, owner.id); + const members = await findRows(orm, TripMembers, { trip: trip.id }); + expect(members).toEqual([expect.objectContaining({ user_id: guest.id, invited_by: owner.id })]); + }); + + it('ORMF-023: makePlace files under the first category unless told otherwise; tagPlace links tags once', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id); + const [first] = await findRows(orm, Categories, {}, { id: 'asc' }); + const place = await makePlace(orm, trip.id); + expect(place.category_id).toBe(first?.id); + expect((await makePlace(orm, trip.id, { category: null })).category_id).toBeNull(); + const own = await makeCategory(orm, { name: 'Mine' }); + expect((await makePlace(orm, trip.id, { category: own.id })).category_id).toBe(own.id); + + const tag = await makeTag(orm, user.id, { name: 'Beach' }); + await tagPlace(orm, place.id, [tag.id]); + await tagPlace(orm, place.id, [tag.id]); + const links = await inContext(orm, (em) => em.getRepository(Tags).listPlaceTagsForTrip(trip.id)); + expect(links).toEqual([{ place_id: place.id, tag_id: tag.id }]); + }); + + it('ORMF-024: itinerary, booking, budget, packing, to-do and file factories attach to the trip', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id, { start_date: '2026-06-01', end_date: '2026-06-02' }); + const [day] = await readTripDays(orm, trip.id); + const place = await makePlace(orm, trip.id); + + const first = await makeDayAssignment(orm, day.id, place.id); + const second = await makeDayAssignment(orm, day.id, place.id); + expect([first.order_index, second.order_index]).toEqual([0, 1]); + expect((await makeDayNote(orm, day.id, trip.id)).sort_order).toBe(9999); + + const reservation = await makeReservation(orm, trip.id, { day: day.id }); + expect(reservation).toMatchObject({ trip_id: trip.id, day_id: day.id, type: 'flight', status: 'pending' }); + expect((await makeReservationEndpoint(orm, reservation.id)).reservation_id).toBe(reservation.id); + + const item = await makeBudgetItem(orm, trip.id, { total_price: 250 }); + expect(item).toMatchObject({ category: 'Transport', total_price: 250 }); + expect((await addBudgetItemMember(orm, item.id, user.id)).budget_item_id).toBe(item.id); + + expect((await makePackingItem(orm, trip.id)).checked).toBe(0); + const bag = await makePackingBag(orm, trip.id); + await addPackingBagMembers(orm, bag.id, [user.id]); + expect(await inContext(orm, (em) => em.getRepository(PackingBags).listMemberIdsForBag(bag.id))).toEqual([user.id]); + + expect((await makeTodoItem(orm, trip.id)).sort_order).toBe(0); + expect((await makeTodoItem(orm, trip.id)).sort_order).toBe(1); + + const file = await makeTripFile(orm, trip.id); + expect((await linkFile(orm, file.id, { reservation: reservation.id })).reservation_id).toBe(reservation.id); + expect(await makeShareToken(orm, trip.id, user.id)).toMatchObject({ trip_id: trip.id, created_by: user.id }); + }); +}); + +describe('ORM test factories: journeys, tours, collections and the rest', () => { + it('ORMF-030: makeJourney adds the owner as contributor; contributors and trip links are idempotent', async () => { + const { user: owner } = await makeUser(orm); + const { user: editor } = await makeUser(orm); + const journey = await makeJourney(orm, owner.id); + await addJourneyContributor(orm, journey.id, editor.id); + await addJourneyContributor(orm, journey.id, editor.id, 'viewer'); + const roles = (await findRows(orm, JourneyContributors, { journey: journey.id })).map((c) => [c.user_id, c.role]); + expect(roles).toEqual(expect.arrayContaining([[owner.id, 'owner'], [editor.id, 'editor']])); + expect(roles).toHaveLength(2); + + const trip = await makeTrip(orm, owner.id); + await linkTripToJourney(orm, journey.id, trip.id); + await linkTripToJourney(orm, journey.id, trip.id); + expect(await countRows(orm, JourneyTrips, { journey: journey.id })).toBe(1); + expect((await makeJourneyEntry(orm, journey.id, owner.id)).visibility).toBe('private'); + }); + + it('ORMF-031: makeTour keys the tour by its place and the waypoints run start, via, end', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id); + const place = await makePlace(orm, trip.id); + expect(await makeTour(orm, place.id)).toMatchObject({ place_id: place.id, tour_type: 'hike' }); + await addTourWaypoints(orm, place.id, [ + { lat: 1, lng: 1 }, + { lat: 2, lng: 2 }, + { lat: 3, lng: 3 }, + ]); + const roles = (await findRows(orm, TourWaypoints, { place: place.id }, { sequence: 'asc' })).map((w) => w.role); + expect(roles).toEqual(['start', 'via', 'end']); + }); + + it('ORMF-032: collections, notifications, settings, plugins and tokens land where the app reads them', async () => { + const { user } = await makeUser(orm); + const { user: friend } = await makeUser(orm); + + const collection = await makeCollection(orm, user.id); + expect((await addCollectionMember(orm, collection.id, friend.id)).status).toBe('accepted'); + expect((await makeCollectionPlace(orm, collection.id, user.id)).collection_id).toBe(collection.id); + + expect(await makeNotification(orm, user.id)).toMatchObject({ recipient_id: user.id, is_read: 0 }); + await disableNotificationPref(orm, user.id, 'trip_invite', 'email'); + await disableNotificationPref(orm, user.id, 'trip_invite', 'email'); + expect(await findRows(orm, NotificationChannelPreferences, { user: user.id })).toEqual([ + expect.objectContaining({ event_type: 'trip_invite', channel: 'email', enabled: 0 }), + ]); + + await setAppSetting(orm, 'factory_probe', 'one'); + await setAppSetting(orm, 'factory_probe', 'two'); + expect(await readAppSetting(orm, 'factory_probe')).toBe('two'); + await setUserSetting(orm, user.id, 'theme', 'dark'); + await setUserSetting(orm, user.id, 'theme', 'light'); + expect(await readUserSetting(orm, user.id, 'theme')).toBe('light'); + + const seeded = await findRow(orm, Addons, { id: 'packing' }); + await setAddonEnabled(orm, 'packing', false); + const flipped = await findRow(orm, Addons, { id: 'packing' }); + expect(Boolean(flipped?.enabled)).toBe(false); + // Only `enabled` changes on an addon the instance already knows. + expect(flipped?.name).toBe(seeded?.name); + expect(flipped?.type).toBe(seeded?.type); + await setAddonEnabled(orm, 'factory-addon', true); + const created = await findRow(orm, Addons, { id: 'factory-addon' }); + expect(created).toMatchObject({ name: 'factory-addon', type: 'global' }); + expect(Boolean(created?.enabled)).toBe(true); + + expect((await makePlugin(orm, 'factory-plugin')).status).toBe('inactive'); + await setPluginUserConfig(orm, 'factory-plugin', user.id, { a: 1 }); + expect((await findRow(orm, PluginUserConfig, { plugin_id: 'factory-plugin', user_id: user.id }))?.config).toBe('{"a":1}'); + + const { token, rawToken } = await makeMcpToken(orm, user.id); + expect(token.token_prefix).toBe(rawToken.slice(0, 12)); + expect(token.token_hash).not.toContain(rawToken); + expect((await makeOauthClient(orm, user.id)).client.user_id).toBe(user.id); + expect((await makeInviteToken(orm, user.id)).created_by).toBe(user.id); + }); + + it('ORMF-033: atlas, vacay, photo and collab factories', async () => { + const { user } = await makeUser(orm); + const trip = await makeTrip(orm, user.id); + + expect((await makeBucketListItem(orm, user.id)).name).toBe('Test Destination'); + await markCountryVisited(orm, user.id, 'de'); + expect(await findRow(orm, VisitedCountries, { user: user.id })).toMatchObject({ country_code: 'DE' }); + + const plan = await makeVacayPlan(orm, user.id); + expect((await addVacayPlanMember(orm, plan.id, user.id)).status).toBe('accepted'); + expect((await makeVacayEntry(orm, plan.id, user.id, '2026-07-01')).date).toBe('2026-07-01'); + + const album = await addAlbumLink(orm, trip.id, user.id, 'immich', 'album-1'); + const photo = await addTripPhoto(orm, trip.id, user.id, 'asset-1', 'immich', { shared: true, albumLinkId: album.id }); + const again = await addTripPhoto(orm, await makeTrip(orm, user.id).then((t) => t.id), user.id, 'asset-1', 'immich'); + expect(photo).toMatchObject({ shared: 1, album_link_id: album.id }); + expect(again.photo_id).toBe(photo.photo_id); + expect(await countRows(orm, TrekPhotos, { asset_id: 'asset-1' })).toBe(1); + + expect((await makeCollabNote(orm, trip.id, user.id)).category).toBe('General'); + expect((await makeCollabMessage(orm, trip.id, user.id)).text).toBe('Test message'); + }); +}); diff --git a/server/tests/unit/scripts/test-sql-ratchet.test.ts b/server/tests/unit/scripts/test-sql-ratchet.test.ts new file mode 100644 index 000000000..9f82c629a --- /dev/null +++ b/server/tests/unit/scripts/test-sql-ratchet.test.ts @@ -0,0 +1,205 @@ +/** + * scripts/test-sql-ratchet.mjs is the CI gate that keeps raw better-sqlite3 + * statements in the server tests from growing back while the suite moves onto + * the ORM factories (tests/helpers/factories). A gate nobody tests passes + * forever once it breaks, so this runs the real script as a child process + * against throwaway server roots (--dir), proves it fails on growth and on + * stale entries, honours the exemption list and the allow marker, and refuses + * a broken setup instead of passing silently. The last case runs it on this + * repository, so the committed baseline is checked by the unit suite too. + */ +import { execFileSync } from 'node:child_process'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; + +const SCRIPT = path.join(__dirname, '../../../scripts/test-sql-ratchet.mjs'); +const SERVER_ROOT = path.join(__dirname, '../../..'); + +interface ExecError { + status: number | null; + stdout: string; + stderr: string; +} + +interface Baseline { + exempt: Record; + counts: Record; +} + +// Spelled in pieces so this file holds no raw call of its own for the ratchet to count. +const CALL = ['.', 'prepare('].join(''); +const stmt = (n: number) => `db${CALL}'SELECT 1').get();\n`.repeat(n); + +const roots: string[] = []; + +function serverRoot(files: Record, baseline: unknown = { exempt: {}, counts: {} }): string { + // null writes no baseline file at all. + const dir = mkdtempSync(path.join(tmpdir(), 'trek-test-sql-')); + roots.push(dir); + mkdirSync(path.join(dir, 'tests'), { recursive: true }); + mkdirSync(path.join(dir, 'scripts'), { recursive: true }); + for (const [file, text] of Object.entries(files)) { + mkdirSync(path.dirname(path.join(dir, file)), { recursive: true }); + writeFileSync(path.join(dir, file), text); + } + if (baseline !== null) { + writeFileSync( + path.join(dir, 'scripts/test-sql-baseline.json'), + typeof baseline === 'string' ? baseline : JSON.stringify(baseline), + ); + } + return dir; +} + +function run(dir: string, ...args: string[]): { status: number | null; out: string } { + try { + const stdout = execFileSync(process.execPath, [SCRIPT, `--dir=${dir}`, ...args], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + return { status: 0, out: stdout }; + } catch (err) { + const { status, stdout, stderr } = err as ExecError; + return { status, out: `${stdout}${stderr}` }; + } +} + +function readBaseline(dir: string): Baseline { + return JSON.parse(readFileSync(path.join(dir, 'scripts/test-sql-baseline.json'), 'utf8')) as Baseline; +} + +afterEach(() => { + for (const dir of roots.splice(0)) rmSync(dir, { recursive: true, force: true }); +}); + +describe('test-sql-ratchet.mjs', () => { + it('TSQL-001: passes when no test file holds a raw statement', () => { + const dir = serverRoot({ 'tests/a.test.ts': 'await makeUser(orm);\n' }); + const { status, out } = run(dir); + expect(status).toBe(0); + expect(out).toContain('test-sql: 0 raw statement(s) in 0 baselined file(s), 0 file(s) exempt'); + }); + + it('TSQL-002: fails a file without a baseline entry that holds one', () => { + const { status, out } = run(serverRoot({ 'tests/unit/new.test.ts': stmt(1) })); + expect(status).toBe(1); + expect(out).toContain( + 'FAIL tests/unit/new.test.ts: 1 raw statement(s), a file without a baseline entry may hold none.', + ); + }); + + it('TSQL-003: fails a baselined file that grows past its entry, passes at it', () => { + const baseline = { exempt: {}, counts: { 'tests/a.test.ts': 2 } }; + const grown = run(serverRoot({ 'tests/a.test.ts': stmt(3) }, baseline)); + expect(grown.status).toBe(1); + expect(grown.out).toContain('FAIL tests/a.test.ts: 3 raw statement(s), its baseline is 2.'); + expect(run(serverRoot({ 'tests/a.test.ts': stmt(2) }, baseline)).status).toBe(0); + }); + + it('TSQL-004: counts every call on a line, in helpers too, and walks .ts and .mjs alike', () => { + const dir = serverRoot({ + 'tests/helpers/seed.ts': `db${CALL}'a').run(); db${CALL}'b').run();\n`, + 'tests/tools/x.mjs': stmt(1), + 'tests/notes.md': stmt(5), + }); + const { status, out } = run(dir); + expect(status).toBe(1); + expect(out).toContain('tests/helpers/seed.ts: 2 raw'); + expect(out).toContain('tests/tools/x.mjs: 1 raw'); + expect(out).not.toContain('notes.md'); + }); + + it('TSQL-005: an allow marker with a reason on the line or the line above takes the statement out of the count', () => { + const text = + `// test-sql-allow: PRAGMA introspection has no ORM equivalent\n${stmt(1)}` + + `db${CALL}'PRAGMA table_info(x)').all(); // test-sql-allow: same reason\n`; + expect(run(serverRoot({ 'tests/a.test.ts': text })).status).toBe(0); + }); + + it('TSQL-006: a marker without a reason, or two lines up, does not count', () => { + const bare = run(serverRoot({ 'tests/a.test.ts': `// test-sql-allow:\n${stmt(1)}` })); + expect(bare.status).toBe(1); + const far = run(serverRoot({ 'tests/b.test.ts': `// test-sql-allow: reason\n\n${stmt(1)}` })); + expect(far.status).toBe(1); + }); + + it('TSQL-007: an exempt file is not counted, and an exemption for a gone file fails', () => { + const exempt = { 'tests/unit/db/dialect.test.ts': 'pins the SQL each dialect function renders' }; + const ok = run(serverRoot({ 'tests/unit/db/dialect.test.ts': stmt(40) }, { exempt, counts: {} })); + expect(ok.status).toBe(0); + expect(ok.out).toContain('1 file(s) exempt'); + + const gone = run(serverRoot({ 'tests/a.test.ts': '' }, { exempt, counts: {} })); + expect(gone.status).toBe(1); + expect(gone.out).toContain('FAIL tests/unit/db/dialect.test.ts is exempt in scripts/test-sql-baseline.json, but the file is gone'); + }); + + it('TSQL-008: fails an entry above its file, or for a file with none left, until --update lowers it', () => { + const shrunk = run(serverRoot({ 'tests/a.test.ts': stmt(1) }, { exempt: {}, counts: { 'tests/a.test.ts': 3 } })); + expect(shrunk.status).toBe(1); + expect(shrunk.out).toContain('FAIL tests/a.test.ts is held at 3 in scripts/test-sql-baseline.json, but it has 1 now.'); + expect(shrunk.out).toContain('npm run lint:test-sql -- --update'); + + const converted = run(serverRoot({ 'tests/a.test.ts': '' }, { exempt: {}, counts: { 'tests/a.test.ts': 3 } })); + expect(converted.status).toBe(1); + expect(converted.out).toContain('but it has none left (or the file is gone)'); + }); + + it('TSQL-009: --update only lowers and drops counts, never adds one, never touches the exemptions', () => { + const exempt = { 'tests/raw.test.ts': 'raw by nature' }; + const dir = serverRoot( + { + 'tests/shrunk.test.ts': stmt(2), + 'tests/converted.test.ts': '', + 'tests/grown.test.ts': stmt(9), + 'tests/new.test.ts': stmt(1), + 'tests/raw.test.ts': stmt(4), + }, + { + exempt, + counts: { 'tests/shrunk.test.ts': 5, 'tests/converted.test.ts': 2, 'tests/grown.test.ts': 6, 'tests/gone.test.ts': 1 }, + }, + ); + const { status } = run(dir, '--update'); + expect(readBaseline(dir)).toEqual({ + exempt, + counts: { 'tests/grown.test.ts': 6, 'tests/shrunk.test.ts': 2 }, + }); + // The grown file and the new one still fail after the update. + expect(status).toBe(1); + }); + + it('TSQL-010: fails closed on a missing, unparsable or malformed baseline', () => { + const missing = serverRoot({ 'tests/a.test.ts': '' }, null); + expect(run(missing).status).toBe(1); + expect(run(missing).out).toContain('test-sql-baseline.json cannot be read'); + + expect(run(serverRoot({}, '{ not json')).status).toBe(1); + expect(run(serverRoot({}, '[]')).status).toBe(1); + expect(run(serverRoot({}, { counts: {} })).status).toBe(1); + // A count that is not a whole number above zero, an exemption without a + // reason, or a file both exempt and counted is a hand edit gone wrong. + expect(run(serverRoot({}, { exempt: {}, counts: { 'tests/a.test.ts': 0 } })).status).toBe(1); + expect(run(serverRoot({}, { exempt: {}, counts: { 'tests/a.test.ts': '2' } })).status).toBe(1); + expect(run(serverRoot({}, { exempt: { 'tests/a.test.ts': ' ' }, counts: {} })).out).toContain('needs a reason'); + expect( + run(serverRoot({}, { exempt: { 'tests/a.test.ts': 'why' }, counts: { 'tests/a.test.ts': 1 } })).out, + ).toContain('both exempt and counted'); + }); + + it('TSQL-011: fails closed when tests/ is missing', () => { + const dir = serverRoot({}); + rmSync(path.join(dir, 'tests'), { recursive: true }); + const { status, out } = run(dir); + expect(status).toBe(1); + expect(out).toContain('tests/ does not exist'); + }); + + it('TSQL-012: the committed tree matches the committed baseline', () => { + const { status, out } = run(SERVER_ROOT); + expect(out).not.toContain('FAIL'); + expect(status).toBe(0); + }); +}); diff --git a/wiki/Development-environment.md b/wiki/Development-environment.md index 0516e503e..48a78f7de 100644 --- a/wiki/Development-environment.md +++ b/wiki/Development-environment.md @@ -176,6 +176,7 @@ The `@trek/shared` package is the single source of truth for code shared between | `npm run lint:size` | No source file grows past its line limit or its baseline entry (CI gate) | | `npm run lint:boundaries` | Import cycles and domain boundaries may not grow past their baseline (CI gate) | | `npm run lint:tx` | Methods that write more than once outside one transaction may not grow past their baseline (CI gate) | +| `npm run lint:test-sql` | Raw SQL fixtures in `tests/` may not grow past their baseline (CI gate) | | `npm run format` | Format source | ### Client (`/client`) @@ -236,7 +237,7 @@ Then open a Pull Request from your fork to `liketrek/TREK` targeting the `dev` b - Always branch off from an up-to-date `dev` — run `git fetch upstream && git rebase upstream/dev` before starting new work. - Run tests before pushing: `npm test` at the repo root runs all workspaces. That alone is not the full CI gate. With `shared` built, these are the checks CI runs before any test (`.github/workflows/test.yml` is the source of truth): - in `shared/`: `npm run typecheck && npm run contracts:open` - - in `server/`: `npm run build && npm run typecheck && npm run typecheck:tests && npm run typecheck:scripts && npm run check:entities && npm run check:db-types && npm run lint:check && npm run db:call-graph -- --sync --tx && npm run lint:size && npm run lint:boundaries && npm run lint:tx && npm run lint:dialect && npm run check:plugin-facts` + - in `server/`: `npm run build && npm run typecheck && npm run typecheck:tests && npm run typecheck:scripts && npm run check:entities && npm run check:db-types && npm run lint:check && npm run db:call-graph -- --sync --tx && npm run lint:size && npm run lint:boundaries && npm run lint:tx && npm run lint:test-sql && npm run lint:dialect && npm run check:plugin-facts` - in `client/`: `npm run typecheck && npm run lint:warnings && npm run lint:pages && npm run lint:rtl && npm run lint:size && npm run lint:format && npm run lint:layers && npm run lint:offline && npm run lint:skips && npm run lint:i18n-keys` - at the root, if you touched translations: `npm run i18n:parity:strict --workspace=shared` - Follow the commit message conventions described in the [[Contributing]] guidelines.