mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
d757478088
* 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>
321 lines
16 KiB
Markdown
321 lines
16 KiB
Markdown
# 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:
|
||
|
||
1. **The mocked shape must be complete**, because a consumer may reach any export.
|
||
2. **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.
|
||
3. **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 times`
|
||
failure 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:
|
||
|
||
```ts
|
||
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:
|
||
|
||
```ts
|
||
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.fn` member (`mockResolvedValue`, `mock`,
|
||
`mockReset`, …) hits the function.
|
||
- Any other string property returns a **cached** child node — same object every time, so
|
||
`dbMock.dbRead.image.findMany` is one stable identity forever.
|
||
- `then`, `catch`, `finally` and unknown symbols return `undefined`, so a node is never
|
||
mistaken for a thenable and `await`ing 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`):
|
||
|
||
```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**:
|
||
|
||
```ts
|
||
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 `const`s 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.
|