* fix(block): enforce blocking on legacy model comments and review edits The write guard covers the CommentsV2 surfaces, but the older model-comment path was never wired into it. The value it passed as the block target is the comment's author — the caller themselves on a create — so a create resolved to a check about the caller and the model owner's block was never consulted. A reply checked only the parent comment's author. Resolve the model owner (and the parent author, for a reply) through the shared block-check service instead, and enforce on edits as well as creates: an edit is scoped by comment id while writing modelId/parentId through from the request, so both the comment's stored home and the one the request names are checked. Refused writes 404, matching the other write paths. Resource reviews had the create-only half of the same shape — a review written before a block stayed editable afterwards — and an edit can re-point modelId, so both models are checked there too. Tests: each negative assertion was verified by mutating the guard away and confirming it fails in under two seconds. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(block): guard the review procedures the UI calls, and 3D model reviews Review round on the previous commit found the review half aimed at the wrong procedure. `resourceReview.upsert` is only reached from the edit-review modal; the app calls `resourceReview.create` and `.update`, and neither had a block check at all — so an author refused on `upsert` had an unguarded path to the same row. Both are guarded now. `update` carries no modelId, so it resolves the review's model from the stored row. 3D model reviews had no check on either branch, while comment threads hanging off such a review were already guarded — which made the surface look covered. Re-homing is already refused there, so one check against the target model does both branches. The legacy-comment resolver now answers through `getBlockCheckOwnerIds` rather than reading the two rows itself, so a rule added to either entity type reaches this path too. `commentOld` resolves the model owner alongside the comment author, matching its CommentsV2 counterpart — author alone still let a blocked user react under a blocker's model. Tests: mocks key on the id being asked for rather than on call order, after identical reply-resolution failures under load turned out to be an empty default being indistinguishable from a real answer. Every guard was mutation-checked individually. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(block): assert the reaction behaviour the widened commentOld arm changes `commentOld` has one consumer, `toggleReaction` — the legacy comment surface has no reaction of its own — so resolving the model owner there changes reaction behaviour: reacting to somebody else's comment under a blocker's model now refuses. That arrived as a side effect of routing the resolver through the shared switch; give it its own assertions, in both directions. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(block): restore the moderator exemption on the 3D-review guard A reviewer's mutation probe was live in the working tree and got swept into the previous commit by a broad `git add`, leaving the guard with a hardcoded `isModerator: false` — so a moderator blocked by a creator would be refused on that creator's 3D-model reviews, letting any user lock a moderator out of a review surface by blocking them. The suite catches it (`exempts moderators`); the commit that introduced it was pushed on the strength of a full-suite run that predated the sweep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(block): pin the handler wiring, and read the guard's target from the writer The review handlers are the half of the fix that reaches production — the guard only knows who is acting because the handler passes it — and no test touched them. Dropping `userId: ctx.user.id` left the guard asking whether an undefined user is blocked: a silent no-op on the path the guard exists to close, with nothing red. Pinned per procedure, along with the moderator status. `userId`/`isModerator` are guard inputs, not columns, and reach the service in one object with the review's fields; a destructure that stops pulling them out sends them into the Prisma payload, which on update rewrites the review's author. Asserted on both payloads. The stored-review lookup read the replica while the write it guards goes through the writer, so a review created seconds earlier resolved no owner and the write proceeded — the fall-through was to a real write, not to a not-found. It reads through the writer now. Also: the moderator-exemption test now covers the upsert branch it was named for, and the new handler test uses the canonical redis mock rather than a direct one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(block): one owner map, one statement of the moderator rule The widened `commentOld` arm still read the model row itself, one arm away from `case 'model'` answering the same question — so a rule added there would reach every path except that one. It recurses now, the way `case 'comment'` already does. `throwIfBlockedByModelOwners` stated the moderator bypass a second time before handing the same flag to the shared guard. Dropped the local early return; the argument stays, and deleting it fails `exempts moderators on every branch`. Also documents a measurement hazard found while verifying this branch: two vitest runs in one worktree make configured mock lookups return their empty default, so a targeted run beside a full suite produces reds the code does not explain. 10/10 green frozen and alone, 1/5 red with a suite alongside, 6/6 green under CPU load with no rival vitest. The mechanism is unproven and the text says so. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
16 KiB
Shared-module mocks: running the unit suite without per-file isolation
Status: design + pilot. The system described here exists; the migration does not.
The unit suite spends ~81% of its worker-time importing modules. vitest --no-isolate
collapses that (2266 worker-seconds to 229 at 8 workers) because each worker builds the
module graph once instead of once per test file. It also breaks ~1,500 tests. This
document is how to get the first without the second.
The mechanism, precisely
The one-line version — "vi.mock is per file, module instances are per worker" — is
right but under-specified, and the missing detail decides the design. Measured with a
two-file probe (--no-isolate --max-workers=1):
A test file that does not mock a module still gets the real module. The poisoning is
not direct. A file with no vi.mock('./target') imports the genuine ./target, even
after another file in the same worker mocked it.
The damage travels through shared NON-test modules. Any ordinary source module that
imports a mocked module — ~/server/redis/fail-open-log importing safeError from
~/server/logging/client, say — is itself cached per worker. It captures its import
bindings once, at its first evaluation, in whatever mock context that first evaluation
happened to occur. Every later file reuses that cached module, still pointing at the
first file's mock object.
The probe reproduces the exact error from the real suite:
Error: [vitest] No "beta" export is defined on the "./target" mock.
❯ useBeta consumer.ts:11:17
❯ 2-victim.test.ts:11:12
2-victim.test.ts contains no vi.mock at all. It failed because consumer.ts was
first evaluated while 1-poisoner.test.ts's partial mock was installed.
Three consequences follow, and they are the whole specification:
- The mocked shape must be complete, because a consumer may reach any export.
- Function identities must be stable for the worker's lifetime, because consumers captured those bindings and will never re-read them. Behaviour has to be swapped by mutating a function, never by replacing it.
- Per-file state must be reset between files, because the object outlives the file.
This includes spy call counts, which is the
expected "X" to be called 2 timesfailure class.
The damage has a silent form. When the poisoned import is reached at module scope rather
than inside a test, the file throws while being imported and collects ZERO tests. The failure
count does not rise, the summary line does not mention it, and the run reads as green while
those tests simply did not happen — measured at nine files and 152 tests on the 90-file
yardstick subset. So the success criterion for any run under --no-isolate is a per-file
collected COUNT diffed against an isolated control, never a colour. Same shape as the repo's
existing trap where a missing event-engine-common submodule makes
blocks.router.workflow.test.ts collect 0 and the run still passes.
setupFiles re-run per test file under --no-isolate — verified in the same probe
(setup run #1, setup run #2, same pid, same shared-module instance). That is the
reset hook, and it is why this is tractable at all.
The shape
One canonical mock module per shared specifier. It is imported by the global setup file, which registers it for every test file and resets it between them. A test file never mocks these specifiers itself; it only declares behaviour.
src/__tests__/mocks/
hybrid.ts the auto-vivifying callable-proxy primitive
db.mock.ts ~/server/db/client
redis.mock.ts ~/server/redis/client
logging.mock.ts ~/server/logging/client
index.ts resetSharedMocks()
src/__tests__/setup.ts gains:
import { dbMock } from './mocks/db.mock';
import { resetSharedMocks } from './mocks';
vi.mock('~/server/db/client', () => dbMock);
resetSharedMocks();
🔴 Spread the package, never importOriginal of a shim
~/server/db/client and ~/server/redis/client are shims: they re-export their package
wholesale and construct real clients at module scope. Registering them with an
importOriginal spread evaluates the shim, which forces real Prisma/Redis construction into
every test file — where before, only files without their own db mock paid it.
That failed in the worst available shape. A file whose own @prisma/client mock omitted
PrismaClient died during module evaluation, so it collected zero tests: the failure
count stayed at 0 and the run read as green.
The package re-exports are all the spread was ever protecting, so spread the package:
vi.mock('~/server/db/client', async () => ({
...(await import('@civitai/db/client')),
dbRead: dbMock.dbRead,
dbWrite: dbMock.dbWrite,
}));
Same exports, nothing constructed. This removes the precondition rather than enumerating the
files that trip it — a file no longer needs its own db mock to be protected. Pinned by a test
asserting neither shim's globalThis client cache is populated, since an absent global is
direct evidence the module body never ran.
The primitive: a hybrid callable proxy
Prisma's surface is roughly 60 models times 15 methods; Redis has a few hundred commands.
Hand-listing either is how the current mocks got partial in the first place. Instead one
primitive covers all three modules: a node that is simultaneously a vi.fn() and a proxy
that vivifies child nodes on property access.
- Property access that resolves to a real
vi.fnmember (mockResolvedValue,mock,mockReset, …) hits the function. - Any other string property returns a cached child node — same object every time, so
dbMock.dbRead.image.findManyis one stable identity forever. then,catch,finallyand unknown symbols returnundefined, so a node is never mistaken for a thenable andawaiting one cannot hang.
This gives dbRead.image.findMany(...), dbRead.$queryRaw(...) and
redis.packed.get(...) without enumerating anything.
Known collision: a model or command literally named after a vi.fn member
(name, call, bind, length, mock*) resolves to the function's own member instead
of a child node. None of the current Prisma models or Redis wrappers hit this. The escape
hatch is mockNode('dbRead.call.findMany'), which addresses a node by path and bypasses
property lookup.
Reset
resetSharedMocks() calls mockReset() on every vivified node, then re-applies that
node's registered default. It runs once per test file, from the setup file, before the
test module is imported. Defaults exist so that an unmigrated code path reaching an
undeclared method gets a plausible empty answer rather than undefined.map is not a function:
| path shape | default |
|---|---|
findMany, findManyAndCount |
[] |
findUnique, findFirst |
null |
count |
0 |
$queryRaw, $queryRawUnsafe |
[] |
$executeRaw, $executeRawUnsafe |
0 |
$transaction |
runs the callback with dbWrite, or Promise.all for an array |
| everything else | undefined |
Defaults are deliberately conservative. A test that cares about a return value must say so; the defaults only stop an unrelated code path from throwing.
What a call site looks like
Before (src/__tests__/pages/api/download/vault-blocklist.test.ts):
const mockFindUnique = vi.hoisted(() => vi.fn());
const mockVaultItemFindUnique = vi.hoisted(() => vi.fn());
vi.mock('~/server/db/client', () => ({
dbRead: {
keyValue: { findUnique: mockFindUnique },
vaultItem: { findUnique: mockVaultItemFindUnique },
modelVersion: { findUnique: mockModelVersionFindUnique },
},
dbWrite: { keyValue: { findUnique: mockFindUnique } },
}));
beforeEach(() => {
mockFindUnique.mockReset();
mockFindUnique.mockResolvedValue({ value: ['1'] });
});
After:
import { dbMock } from '~/__tests__/mocks/db.mock';
const mockFindUnique = dbMock.dbRead.keyValue.findUnique;
const mockVaultItemFindUnique = dbMock.dbRead.vaultItem.findUnique;
beforeEach(() => {
mockFindUnique.mockResolvedValue({ value: ['1'] });
});
The vi.mock call and the vi.hoisted wrappers both go away. The local consts stay,
so the body of the test file needs no edits at all — which is what makes the migration
mechanical. The mockReset() in beforeEach also goes away, because the setup file
already reset everything before this file was imported.
One real change of meaning: dbRead and dbWrite were often aliased to the same mock
object, so a dbWrite call satisfied a dbRead assertion. They are now distinct. A file
that relied on the aliasing has to name the client it actually exercises — that is a
fixture collision being corrected, not a regression.
Migration is all-or-nothing per specifier
A single unmigrated file that still calls vi.mock('~/server/db/client', …) re-poisons
every consumer module in its worker, and which files it takes down depends on sharding.
So a specifier is only fixed when its last call site is converted — 441 files for
~/server/db/client, 200 for logging, 198 for redis.
That is enforced, not remembered: no-direct-shared-module-mock fails when a test file
mocks a canonical specifier, carrying an explicit allowlist of the files not yet
converted. The allowlist only ever shrinks, and its length is the migration's progress
bar.
A green pilot run therefore proves the mechanism, not the suite. Converted files are run together, without unconverted ones, at two worker counts. Mixing them measures the residual, which is a different question — measured: the same 174 files went from 404 failures to 290 with 83 of them migrated, and the fully-migrated 83 alone came in at 11.
Measured result
83 files, migrated for all three specifiers, against the same files isolated at 4 workers:
| run | files | tests | failed | zero-test files | wall |
|---|---|---|---|---|---|
| isolated, 4 workers (control) | 83 | 856 | 0 | 0 | 62.6s |
--no-isolate, 4 workers |
83 | 846 | 11 | 1 | 22.9s |
--no-isolate, 12 workers |
83 | 853 | 1 | 1 | 18.8s |
The residual failures are not mock shape. They are production modules holding state at module
scope — eventloop-longtask.ts registers its metrics into a real prom registry on import, so
under --no-isolate the first file to import it in a worker takes the registration and every
later file sees an empty registry. That belongs with no-module-scope-cache. The one
zero-collection file dies on ~/server/services/image.service, which is out of scope here.
Registering the canonical mocks is inert under isolation: the same files isolated at 4 workers report 0 failures.
Measuring
The yardstick is node scripts/test-perf/bench.mjs --label <name> --workers <n>, a fixed
90-file subset, with --no-isolate passed through. Every claim needs two worker counts
because the failing set is order-dependent — 1574 failures at 8 workers against 1161 at
31, same code, same flag.
Do not read a suite result through | tail or | grep; that reports the pipe's exit
code. Redirect to a file and read the file.
🔴 Never run two vitest runs in one worktree — the second one's result is not evidence.
A targeted run and a full suite sharing a checkout produce assertion failures in the targeted run that the code does not explain. Measured on one frozen SHA, five files, identical bytes on disk for every run: 10/10 green with nothing else in the tree, 1/5 red with a full suite running concurrently in the same worktree, and earlier the same day 5/10 and 4/5 red under that condition. A control rules out load as the cause: 6/6 green under 24 spinning CPU processes with no rival vitest.
The failure reads as a test bug, which is what makes it expensive. The symptom is that a dbRead
lookup the test configured returns the canonical mock's empty default instead — so the code
under test resolves nothing, a guard finds no owner, and an assertion that something is refused
fails as "resolved instead of rejecting". It is not a consumed …Once queue: it reproduces on
plain mockResolvedValue too. The mechanism is unproven; the shared node_modules/.vite cache
is the obvious suspect and has not been demonstrated. CI runs one suite at a time, so this is a
local hazard.
Two rules follow. Anyone's flakiness number measured in a shared worktree — including a green one — says nothing, so re-measure alone before believing it. And before attributing a red run to the diff, check whether anything else was running in that checkout: this condition sent two separate diagnoses the wrong way in one afternoon, once at a test file and once at run contention, and a tree that moves underneath a probe will do the same.
Not covered here
~/server/services/buzz.service (98 sites) and ~/server/services/image.service (88) lead
the next tier. The infra clients above are uniform enough for one auto-vivifying primitive; a
service module has a hand-written surface where the right canonical mock is a hand-written
stub, so it is a different piece of work. (~/env/server has since been done — it needed a
value table rather than a call surface; see the two-bucket rule in the migration doc.)
🔴 The PENDING list in guarded-specifiers.ts is a floor, not an inventory. It was
assembled from the most-mocked specifiers in a static scan, and specifiers keep arriving from
the other direction — as failures in a --no-isolate run of files already residual-clean for
everything listed. ~/server/flipt/client arrived that way. ~/server/middleware/block-scope.middleware
(27 files) is a live candidate found the same way, and is deliberately not added without
someone verifying the mechanism first.
So the remaining work is discovered rather than known, and any estimate built on the
current count is a lower bound. The method for finding the next one is the same each time:
migrate a set clean for everything listed, run it under --no-isolate, and read what still
fails.
The list was built from repo-wide mock counts, and that is why it misses what it misses. Two directories fail for different reasons:
| routers (27 files) | services (195 files) | |
|---|---|---|
| distinct specifiers needed | 33 | 101 |
| not on the PENDING list | 28 | 88 |
| median shared per pair | 10 | 1 |
| pairs sharing nothing | 0 (0%) | 6,149 (32.5%) |
Services is a fan-in problem — a few specifiers reached by very many files — and the PENDING list covers it well: every high-fan-in specifier is on it, and the first undiscovered one ranks 12th. Routers is a clique: its colliding specifiers are locally dense and repo-wide rare, so a count built from repo-wide frequency cannot see them.
So to find the missing ones, build the per-pair shared-specifier graph for the directory you are working on rather than sorting a repo-wide count. The two directories need different searches, not more of the same one.
💡 Untested, and the highest-value idea nobody has tried: a third of services pairs share
no specifier at all. A worker assignment that groups non-overlapping files could make much
of that suite clean under isolate: false without canonicalising anything. It is statically
evaluable from the same graph, before anyone writes code.
The unit of work is the CLUSTER of specifiers a set of files shares, not one specifier.
Canonicalising block-scope.middleware took one pair from 13 failures to 7, and the
remaining 7 were a different class — so that pair shares at least one more poisoning
specifier that nobody has named yet. 13 → 7 is the dangerous shape: it reads as progress and
is not completion. A set is done when its --no-isolate failures reach zero, not when the
specifier you were working on stops appearing.