mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
test(server): seed and read test data through ORM factories
The server suite seeds and reads its database through some 5,500 raw better-sqlite3 statements on the sync handle. They restate the schema by hand, skip the repositories the app goes through and run on SQLite only, so nothing in the suite could ever exercise the dialect layer on another engine. tests/helpers/factories/ adds typed factories per domain (users, trips and days, places, assignments, bookings, budget, packing, to-dos, files, journeys, tours, collections, notifications, settings, plugins, tokens, atlas, vacay, photos, collab) and generic readers (findRow, findRows, countRows, updateRows, deleteRows) that go through MikroORM in a request context of their own, against the app's ORM or a createTestOrm handle. They refuse a write to a read-only foreign-key twin, which MikroORM would otherwise drop without a word. The README there is the conversion guide. The categories integration suite and the tags and calendar-feed e2e suites are converted in full as worked examples; the tags suite also moves from hand-rolled tables to the migrated snapshot. lint:test-sql (scripts/test-sql-ratchet.mjs) holds the raw statements left to a per-file baseline: growth and stale entries fail, files whose subject is raw SQL are exempt with a reason, and a single statement can be allowed with a test-sql-allow comment. CI runs it next to lint:boundaries.
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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: <reason>` 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 `<domain>.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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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=<path> 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;
|
||||
}
|
||||
}
|
||||
@@ -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<ReturnType<typeof build>>;
|
||||
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 () => {
|
||||
|
||||
@@ -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<number> {
|
||||
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<ReturnType<typeof build>>;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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.
|
||||
@@ -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<BucketList>;
|
||||
|
||||
/** A destination on the user's bucket list. */
|
||||
export function makeBucketListItem(
|
||||
orm: FactoryOrm,
|
||||
userId: number,
|
||||
overrides: EntityData<BucketList> = {},
|
||||
): Promise<BucketListRow> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await insertRowIgnoringConflict(orm, VisitedRegions, { user: userId, ...region });
|
||||
}
|
||||
@@ -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<BudgetItems>;
|
||||
|
||||
/** An expense on the trip: "Test Budget Item", Transport, 100 in the trip currency. */
|
||||
export function makeBudgetItem(
|
||||
orm: FactoryOrm,
|
||||
tripId: number,
|
||||
overrides: EntityData<BudgetItems> = {},
|
||||
): Promise<BudgetItemRow> {
|
||||
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<BudgetItemMembers> = {},
|
||||
): Promise<EntityDTO<BudgetItemMembers>> {
|
||||
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<EntityDTO<BudgetItemPayers>> {
|
||||
return createRow(orm, BudgetItemPayers, { budgetItem: budgetItemId, user: userId, amount });
|
||||
}
|
||||
@@ -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<CollabNotes>;
|
||||
export type CollabMessageRow = EntityDTO<CollabMessages>;
|
||||
|
||||
/** A note on the trip's collab board, written by `userId`. */
|
||||
export function makeCollabNote(
|
||||
orm: FactoryOrm,
|
||||
tripId: number,
|
||||
userId: number,
|
||||
overrides: EntityData<CollabNotes> = {},
|
||||
): Promise<CollabNoteRow> {
|
||||
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<CollabMessages> = {},
|
||||
): Promise<CollabMessageRow> {
|
||||
return createRow(orm, CollabMessages, { trip: tripId, user: userId, text: 'Test message', ...overrides });
|
||||
}
|
||||
@@ -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<Collections>;
|
||||
export type CollectionPlaceRow = EntityDTO<CollectionPlaces>;
|
||||
|
||||
/** A collection owned by `ownerId`. */
|
||||
export function makeCollection(
|
||||
orm: FactoryOrm,
|
||||
ownerId: number,
|
||||
overrides: EntityData<Collections> = {},
|
||||
): Promise<CollectionRow> {
|
||||
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<CollectionMembers> = {},
|
||||
): Promise<EntityDTO<CollectionMembers>> {
|
||||
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<CollectionPlaces> = {},
|
||||
): Promise<CollectionPlaceRow> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await inContext(orm, (em) => em.getRepository(CollectionPlaces).assignLabel(collectionPlaceId, labelId));
|
||||
}
|
||||
@@ -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<T>(orm: FactoryOrm, fn: (em: EntityManager) => Promise<T>): Promise<T> {
|
||||
return withRequestContext(orm, () => fn(orm.em));
|
||||
}
|
||||
|
||||
const sequences = new Map<string, number>();
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
@@ -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<TripFiles>;
|
||||
export type FileLinkRow = EntityDTO<FileLinks>;
|
||||
|
||||
/**
|
||||
* 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<TripFiles> = {},
|
||||
): Promise<TripFileRow> {
|
||||
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<EntityData<FileLinks>, 'reservation' | 'assignment' | 'place' | 'budgetItem'>,
|
||||
): Promise<FileLinkRow> {
|
||||
return createRow(orm, FileLinks, { file: fileId, ...target });
|
||||
}
|
||||
@@ -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<DayAssignments>;
|
||||
export type DayNoteRow = EntityDTO<DayNotes>;
|
||||
export type DayAccommodationRow = EntityDTO<DayAccommodations>;
|
||||
|
||||
/** The next free `order_index` on the day: one past the highest, 0 on an empty day. */
|
||||
function nextOrderIndex(orm: FactoryOrm, dayId: number): Promise<number> {
|
||||
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<DayAssignments> = {},
|
||||
): Promise<DayAssignmentRow> {
|
||||
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<DayNotes> = {},
|
||||
): Promise<DayNoteRow> {
|
||||
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<DayAccommodations> = {},
|
||||
): Promise<DayAccommodationRow> {
|
||||
return createRow(orm, DayAccommodations, {
|
||||
trip: tripId,
|
||||
place: placeId,
|
||||
startDay: startDayId,
|
||||
endDay: endDayId,
|
||||
check_in: null,
|
||||
check_out: null,
|
||||
confirmation: null,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -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<Journeys>;
|
||||
export type JourneyEntryRow = EntityDTO<JourneyEntries>;
|
||||
|
||||
/**
|
||||
* 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<Journeys> = {},
|
||||
): Promise<JourneyRow> {
|
||||
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<JourneyEntries> = {},
|
||||
): Promise<JourneyEntryRow> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await insertRowIgnoringConflict(orm, JourneyTrips, { journey: journeyId, trip: tripId, added_at: Date.now() });
|
||||
}
|
||||
@@ -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<Notifications>;
|
||||
|
||||
/** An unread in-app notification of the simple kind for `recipientId`. */
|
||||
export function makeNotification(
|
||||
orm: FactoryOrm,
|
||||
recipientId: number,
|
||||
overrides: EntityData<Notifications> = {},
|
||||
): Promise<NotificationRow> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await upsertRow(orm, NotificationChannelPreferences, { user: userId, event_type: eventType, channel, enabled: 0 });
|
||||
}
|
||||
@@ -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<PackingItems>;
|
||||
export type PackingBagRow = EntityDTO<PackingBags>;
|
||||
|
||||
/** An unchecked item on the trip's packing list, under Clothing. */
|
||||
export function makePackingItem(
|
||||
orm: FactoryOrm,
|
||||
tripId: number,
|
||||
overrides: EntityData<PackingItems> = {},
|
||||
): Promise<PackingItemRow> {
|
||||
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<PackingBags> = {},
|
||||
): Promise<PackingBagRow> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await inContext(orm, (em) => em.getRepository(PackingItems).insertRecipientsIgnore(itemId, userIds));
|
||||
}
|
||||
@@ -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<TripPhotos>;
|
||||
|
||||
/**
|
||||
* 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<TripPhotoRow> {
|
||||
// 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<EntityDTO<TripAlbumLinks>> {
|
||||
return createRow(orm, TripAlbumLinks, {
|
||||
trip: tripId,
|
||||
user: userId,
|
||||
provider,
|
||||
album_id: albumId,
|
||||
album_name: albumName,
|
||||
});
|
||||
}
|
||||
@@ -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<Places>;
|
||||
export type CategoryRow = EntityDTO<Categories>;
|
||||
export type TagRow = EntityDTO<Tags>;
|
||||
|
||||
/** A category; global unless `user` names an owner. */
|
||||
export function makeCategory(orm: FactoryOrm, overrides: EntityData<Categories> = {}): Promise<CategoryRow> {
|
||||
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<Tags> = {}): Promise<TagRow> {
|
||||
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<number | null> {
|
||||
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<Places> = {}): Promise<PlaceRow> {
|
||||
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<void> {
|
||||
await inContext(orm, (em) => em.getRepository(Tags).insertIgnore(placeId, tagIds));
|
||||
}
|
||||
@@ -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<Plugins>;
|
||||
|
||||
/**
|
||||
* 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<Plugins> = {}): Promise<PluginRow> {
|
||||
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<PluginSettingsFields> = {},
|
||||
): Promise<EntityDTO<PluginSettingsFields>> {
|
||||
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<string, unknown>,
|
||||
): Promise<void> {
|
||||
await upsertRow(orm, PluginUserConfig, {
|
||||
plugin_id: pluginId,
|
||||
user_id: userId,
|
||||
config: JSON.stringify(config),
|
||||
updated_at: dbNow(),
|
||||
});
|
||||
}
|
||||
@@ -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<Reservations>;
|
||||
export type ReservationEndpointRow = EntityDTO<ReservationEndpoints>;
|
||||
|
||||
/** A booking on the trip: a flight titled "Test Reservation" unless told otherwise. */
|
||||
export function makeReservation(
|
||||
orm: FactoryOrm,
|
||||
tripId: number,
|
||||
overrides: EntityData<Reservations> = {},
|
||||
): Promise<ReservationRow> {
|
||||
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<ReservationEndpoints> = {},
|
||||
): Promise<ReservationEndpointRow> {
|
||||
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<EntityDTO<ReservationTravelers>> {
|
||||
return createRow(orm, ReservationTravelers, { reservation: reservationId, user: userId });
|
||||
}
|
||||
@@ -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<T extends object>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
data: RequiredEntityData<T>,
|
||||
): Promise<Primary<T>> {
|
||||
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<T extends object>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
data: RequiredEntityData<T>[],
|
||||
): Promise<Primary<T>[]> {
|
||||
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<T extends { id: number }>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
data: RequiredEntityData<T>,
|
||||
): Promise<EntityDTO<T>> {
|
||||
const id = await insertRow(orm, entity, data);
|
||||
const row = await findRow(orm, entity, { id } as FilterQuery<T>);
|
||||
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<T extends object>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
where: FilterQuery<T>,
|
||||
): Promise<EntityDTO<T> | null> {
|
||||
return inContext(orm, async (em) => {
|
||||
const found = await em.findOne(entity, where, { disableIdentityMap: true });
|
||||
return found ? toRow<T>(found) : null;
|
||||
});
|
||||
}
|
||||
|
||||
/** Every row matching `where` (all rows for `{}`), optionally ordered. */
|
||||
export function findRows<T extends object>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
where: FilterQuery<T> = {} as FilterQuery<T>,
|
||||
orderBy?: FindOptions<T>['orderBy'],
|
||||
): Promise<EntityDTO<T>[]> {
|
||||
return inContext(orm, async (em) => {
|
||||
const found = await em.find(entity, where, { disableIdentityMap: true, orderBy });
|
||||
return found.map((row) => toRow<T>(row));
|
||||
});
|
||||
}
|
||||
|
||||
/** How many rows match `where` (all rows for `{}`). */
|
||||
export function countRows<T extends object>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
where: FilterQuery<T> = {} as FilterQuery<T>,
|
||||
): Promise<number> {
|
||||
return inContext(orm, (em) => em.count(entity, where));
|
||||
}
|
||||
|
||||
/** Writes `data` to every row matching `where` and returns how many it touched. */
|
||||
export function updateRows<T extends object>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
where: FilterQuery<T>,
|
||||
data: EntityData<T>,
|
||||
): Promise<number> {
|
||||
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<T extends object>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
where: FilterQuery<T> = {} as FilterQuery<T>,
|
||||
): Promise<number> {
|
||||
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<T extends object, Fields extends string = never>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
data: EntityData<T>,
|
||||
mergeFields?: UpsertOptions<T, Fields>['onConflictMergeFields'],
|
||||
): Promise<void> {
|
||||
return inContext(orm, async (em) => {
|
||||
assertWritable(em, entity, data);
|
||||
await em.upsert<T, Fields>(entity, data, mergeFields ? { onConflictMergeFields: mergeFields } : undefined);
|
||||
});
|
||||
}
|
||||
|
||||
/** Inserts the row unless its key already exists: the raw fixtures' `INSERT OR IGNORE`. */
|
||||
export function insertRowIgnoringConflict<T extends object>(
|
||||
orm: FactoryOrm,
|
||||
entity: EntityClass<T>,
|
||||
data: EntityData<T>,
|
||||
): Promise<void> {
|
||||
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<T extends object>(em: EntityManager, entity: EntityClass<T>, 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}'` : ''),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<string | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await setAppSetting(orm, `collab_${feature}_enabled`, enabled ? 'true' : 'false');
|
||||
}
|
||||
@@ -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<TodoItems>;
|
||||
|
||||
/** An open to-do on the trip, appended after the trip's last one. */
|
||||
export async function makeTodoItem(
|
||||
orm: FactoryOrm,
|
||||
tripId: number,
|
||||
overrides: EntityData<TodoItems> = {},
|
||||
): Promise<TodoItemRow> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -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<McpTokens> & { rawToken?: string } = {},
|
||||
): Promise<{ token: EntityDTO<McpTokens>; 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<EntityData<OauthClients>, 'id'> & { id?: string; secret?: string } = {},
|
||||
): Promise<{ client: EntityDTO<OauthClients>; 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<InviteTokens> = {},
|
||||
): Promise<EntityDTO<InviteTokens>> {
|
||||
return createRow(orm, InviteTokens, {
|
||||
token: `test-invite-${nextSeq('invite-token')}`,
|
||||
max_uses: 1,
|
||||
used_count: 0,
|
||||
createdByRef: createdBy,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -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<Tours>;
|
||||
|
||||
/**
|
||||
* 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<Tours> = {}): Promise<TourRow> {
|
||||
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<void> {
|
||||
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 })),
|
||||
);
|
||||
}
|
||||
@@ -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<Trips>;
|
||||
export type DayRow = EntityDTO<Days>;
|
||||
|
||||
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<TripRow> {
|
||||
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<DayRow> {
|
||||
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<DayRow[]> {
|
||||
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<void> {
|
||||
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<ShareTokens>,
|
||||
| 'token'
|
||||
| 'share_map'
|
||||
| 'share_bookings'
|
||||
| 'share_packing'
|
||||
| 'share_budget'
|
||||
| 'share_collab'
|
||||
| 'share_travel_only'
|
||||
| 'share_hide_images'
|
||||
| 'expires_at'
|
||||
>
|
||||
> = {},
|
||||
): Promise<EntityDTO<ShareTokens>> {
|
||||
return createRow(orm, ShareTokens, {
|
||||
trip: tripId,
|
||||
createdByRef: createdBy,
|
||||
token: overrides.token ?? `share-${nextSeq('share-token')}-${tripId}`,
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
@@ -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<Users>;
|
||||
|
||||
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<n>`,
|
||||
* `user<n>@test.example.com`, `TestPass<n>!`.
|
||||
*/
|
||||
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<UserOverrides, 'role'> = {},
|
||||
): 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<UserRow> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
await updateRows(
|
||||
orm,
|
||||
Users,
|
||||
{ id: userId },
|
||||
{ synology_url: url, synology_username: username, synology_password: encrypt_api_key(password) },
|
||||
);
|
||||
}
|
||||
@@ -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<VacayPlans>;
|
||||
|
||||
/** A vacation plan owned by `ownerId`, with the plan defaults the migration sets. */
|
||||
export function makeVacayPlan(
|
||||
orm: FactoryOrm,
|
||||
ownerId: number,
|
||||
overrides: EntityData<VacayPlans> = {},
|
||||
): Promise<VacayPlanRow> {
|
||||
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<EntityDTO<VacayPlanMembers>> {
|
||||
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<VacayEntries> = {},
|
||||
): Promise<EntityDTO<VacayEntries>> {
|
||||
return createRow(orm, VacayEntries, { plan: planId, user: userId, date, ...overrides });
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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<number> {
|
||||
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));
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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<string, string>;
|
||||
counts: Record<string, number>;
|
||||
}
|
||||
|
||||
// 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<string, string>, 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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user