Files
TREK/server/tests/unit/db/repositories/identity-map-writeback.test.ts
T
Maurice 514d5159ce test(server): seed the repository specs through the ORM factories
The repository suites for assignments, notifications, document sync,
tags, photo metadata, vacay, day notes, notice dismissals, idempotency
keys, settings, travellers, invites, providers, hidden regions, road
trip tracks and the collection role check seeded their fixtures and
read back what a write left behind with raw statements. Fixtures now go
through the ORM factories and insertRow/updateRows, and the state reads
through findRow/findRows/countRows.

What stays raw is raw on purpose and carries a test-sql-allow marker:
the legacy statements every parity case compares a repository against,
the full raw rows used as such an oracle, PRAGMA reads, typeof() and
date() storage checks, and the out-of-band writes the identity map
freshness cases are about, which have to bypass every EntityManager.
2026-10-08 18:45:32 +02:00

98 lines
4.9 KiB
TypeScript

/**
* Regression test for Plan 3b Task 1's B1 finding
* (`.superpowers/sdd/2026-09-22-orm-phase3b/task-1-review.md`): a `Users`
* row read with `fields` + `refresh: true` (no `disableIdentityMap`) left a
* *partially hydrated, managed* entity in the request `EntityManager`'s
* identity map. A second such read, with a *different* field set, on the
* *same* row re-snapshotted only its own fields — the first read's fields
* stayed on the entity object but fell out of the snapshot MikroORM's
* change-set computer diffs against, so they looked like a pending change.
* The next `flush()` (including the implicit one at `UnitOfWork.transactional`
* commit) then emitted an `UPDATE users SET <those columns> …` carrying the
* STALE pre-request values, landing *after* an intervening `nativeUpdate`
* inside the same transaction and silently reverting it.
*
* No single-method test can catch this by construction (`Users.repository.test.ts`
* exercises one method per case): this file drives the two-reads-then-a-write
* interaction that is the actual trigger, inside `withRequestContext` (every
* authenticated request's shape) and `uow.transactional` (every multi-write
* request's shape).
*
* The fix (this task): every row-out read passes `disableIdentityMap: true`.
* MikroORM forks the EM with `keepTransactionContext: true`, loads inside the
* fork, then clears it — nothing is added to the *request's* identity map, so
* there is nothing for a later `flush()` to find dirty.
* See https://mikro-orm.io/docs/entity-manager#disableidentitymap and
* `node_modules/@mikro-orm/core/EntityManager.js:807-824`.
*
* Mutation proof (recorded in `task-1-fix-report.md`, not re-run here): reverting
* ONE of the two reads below to the pre-fix shape (`fields` + `refresh: true`,
* no `disableIdentityMap`) makes IMWB-001 fail with the stale write-back this
* test is built to catch.
*/
import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest';
import { createSnapshotTestDb } from '../../../helpers/db-mock';
import { resetTestDb } from '../../../helpers/test-db';
import { createTestOrm, type TestOrm } from '../../../helpers/test-orm';
import { createUser } from '../../../helpers/factories';
import { Users } from '../../../../src/db/entities/Users.entity';
import type { UsersRepository } from '../../../../src/db/repositories/Users.repository';
import { UnitOfWork } from '../../../../src/nest/database/unit-of-work';
import { withRequestContext } from '../../../../src/nest/database/request-context';
import { readUser } from '../../../helpers/factories/users';
const testDb = createSnapshotTestDb();
let t: TestOrm;
let users: UsersRepository;
let uow: UnitOfWork;
beforeAll(async () => {
t = await createTestOrm(testDb);
users = t.repo(Users);
uow = new UnitOfWork(t.em);
});
beforeEach(() => { resetTestDb(testDb); t.clear(); });
afterAll(async () => { await t.close(); testDb.close(); });
describe('identity-map write-back (task-1-review.md B1)', () => {
it('IMWB-001: findByIdWithPasswordVersion then getApiKeyColumns then patchProfile — the profile write survives the request', async () => {
const { user } = createUser(testDb, { username: 'before', email: 'before@example.test' });
const originalPasswordVersion = (await readUser(t, user.id)).password_version;
await withRequestContext(t.orm, async () => {
// Projection A — the JwtAuthGuard/GlobalAuthGuard shape.
await users.findByIdWithPasswordVersion(user.id);
// Projection B — a second UsersRepository read with a different field set,
// the UserProfileService shape (e.g. the MfaPolicyGuard/getApiKeyColumns
// reads that run on the same request).
await users.getApiKeyColumns(user.id);
// The intended write, inside the request's own transaction.
await uow.transactional(async () => {
await users.patchProfile(user.id, { username: 'newname' });
});
});
const row = await readUser(t, user.id);
expect(row.username).toBe('newname'); // the nativeUpdate must stick
expect(row.email).toBe('before@example.test'); // untouched by either read
expect(row.password_version).toBe(originalPasswordVersion); // untouched by either read
});
it('IMWB-002: findByIdWithPasswordVersion then getApiKeyColumns then setPassword — password_version survives (Task 5 session-invalidation case)', async () => {
const { user } = createUser(testDb, { username: 'someone' });
await withRequestContext(t.orm, async () => {
await users.findByIdWithPasswordVersion(user.id);
await users.getApiKeyColumns(user.id);
await uow.transactional(async () => {
await users.setPassword(user.id, 'NEWHASH', 5);
});
});
const row = await readUser(t, user.id);
expect(row.password_hash).toBe('NEWHASH');
expect(row.password_version).toBe(5); // the session-invalidation gate — must NOT revert to the pre-change value
expect(row.username).toBe('someone'); // untouched by either read
});
});