Skip to content

fs/internal/imptest: cross-backend ocflfs contract test suite - #170

Merged
srerickson merged 11 commits into
mainfrom
claude/gh-issue-162-tests-47m89i
Aug 25, 2026
Merged

fs/internal/imptest: cross-backend ocflfs contract test suite#170
srerickson merged 11 commits into
mainfrom
claude/gh-issue-162-tests-47m89i

Conversation

@exe-dev-github-integration

Copy link
Copy Markdown

Closes #162.

Adds fs/internal/imptest, a shared contract test suite for the ocflfs interfaces, and runs it against both the fs/local and fs/s3 backends. Nothing in the previous suite caught the two backends disagreeing about the ocflfs.WriteFS contract — this suite is where those disagreements now surface.

What lands here

  • fs/internal/imptest — one entry point per interface under test, one file per interface:
    • TestWriteFSWrite / TestWriteFSRemove / TestWriteFSRemoveAll (writefs.go)
    • TestDirEntries (direntriesfs.go) — takes no options struct: the backends agree on every case it covers
    • TestWalkFiles (filewalker.go) — keeps its ErrWalk fixture wired through the skipped subtest
  • Call-recording mockfs/s3/internal/mock gains a mutex-guarded request log (Call{Op, Keys}, Calls(), CallsFor(op)), so a test can tell one DeleteObjects of 500 keys apart from 500 separate DeleteObject calls (needed by fs/s3: delete semantics — missing keys, batching, and partial failures #166).
  • Contract fixes folded inWrite(".") and Remove(".") now return fs.ErrInvalid on every backend, asserted directly rather than through per-backend knobs.

Skips

Per the issue's sequencing constraint, the suite only asserts what main already honors; each known gap is skipped with a TODO naming the issue that closes it:

  • TODO(#163) — failing source leaves previous content intact (write, x2)
  • TODO(#165) — walk error delivered as the pair's error element
  • TODO(#166)Remove of a missing key returns fs.ErrNotExist

Each TODO names which backend was passing before the unconditional skip, so the fixing PR knows what it is switching back on.

claude added 9 commits August 25, 2026 14:03
fs/local and fs/s3 were tested independently, with no shared assertions.
Nothing caught the two backends disagreeing about the ocflfs.WriteFS
contract, which is where the defects in #163-#168 live: a backend can look
correct against its own tests and still be wrong in a way that only shows up
when a caller swaps local storage for S3.

Five entry points, each run against both backends:

  TestWriteFSWriteContract      a shorter overwrite fully replaces (a tail of
                                the previous content is the tell for an
                                in-place write); a failing source leaves the
                                target as it was; an invalid path is rejected
                                with fs.ErrInvalid and no side effect.
  TestWriteFSRemoveContract     a missing file is fs.ErrNotExist on every
                                backend; "." errors and leaves the storage
                                root readable.
  TestWriteFSRemoveAllContract  absent is success, the documented inversion of
                                Remove; the subtree goes and nothing else
                                does; "a" is a path element, not a string
                                prefix, so "ab", "abc.txt" and "a-sibling.txt"
                                survive.
  TestDirEntriesContract        entries are base names, one level deep,
                                sorted. A flat listing naturally yields full
                                keys and the whole subtree, and a caller that
                                joins the entry onto the directory it asked
                                for then builds "dir/dir/file".
  TestWalkFilesContract         early termination when yield returns false; a
                                walk error delivered as the pair's error
                                element with nothing yielded after it; every
                                *FileRef carrying the backend's own instance
                                in FS.

Each config struct carries only what is legitimately backend-specific
(RemoveDotIsNotExist, RemoveAllDotIsError, RemoveAllOnFileRemovesIt,
WriteDotIsError, and the WalkFiles error fixture). Where both backends agree
the suite asserts outright, which is why TestDirEntriesContract takes no
struct at all.

This lands before the fixes, so three cases main does not honor are guarded
by a Skip field naming the issue that closes them, for the fixing PR to
delete: local Write is not atomic (#163), s3 Remove of a missing key returns
nil (#166), and fileWalk panics on an error-yielding DirEntries (#165). The
last also gets a full regression test in fs/walk_contract_test.go, skipped
for now. All three were verified to fail with the skip removed.

The suite lives under fs/internal rather than the top-level internal so it
stays scoped to the fs tree. Contract callers are external test packages
(local_test, s3_test) so the suite can grow backend fixtures without an
import cycle.

fs/s3/internal/mock gains an ordered call log with query helpers (Calls,
CallsFor, CallCount, KeysFor, KeyBatchesFor), guarded by its own mutex
because every method records, including the ones already holding the MPU
lock. KeyBatchesFor is the batching-sensitive view #166 needs to tell one
DeleteObjects of 500 keys from 500 DeleteObject calls. Recording lives in the
mock rather than in per-test wrapper types, so it covers every method rather
than the two a given test remembered to wrap.

Two mock fidelity gaps surfaced while writing the contracts, both of which
limited every S3 test to asserting which requests were sent rather than what
the bucket ended up holding: PutObject did not materialize the object, and
DeleteObject recorded the key in the Deleted map without removing it. Both
now behave like a real store; Deleted stays as call bookkeeping, documented
as such. No existing test depended on the old behavior.

Refs #162.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnxfAdBrXieiGaAENQ74Hh
The suite was one level deeper, at fs/internal/testutil, which put a second
package named testutil in the module alongside the top-level
internal/testutil. Nothing needed the extra directory: fs/internal holds only
this suite, and the shorter path spells out that it belongs to the fs tree.

Mechanical move — package testutil becomes package internal, testutil.go
becomes doc.go, and the callers in fs/local and fs/s3 now read
internal.TestWriteFSRemoveContract(...). No contract, knob, or skip changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnxfAdBrXieiGaAENQ74Hh
The package had no name of its own — it was `package internal` at fs/internal,
which says where it lives and nothing about what it is. It is now imptest, the
fs implementation test suite: assertions every implementation of the ocflfs
interfaces must satisfy, run against each backend from that backend's own
tests.

The word "contract" is gone from exports, file names and docs. It was doing
two jobs at once — naming the suite and naming the thing being tested — and
neither reading was carried by the word itself:

  TestWriteFSWriteContract      -> TestWriteFSWrite
  TestWriteFSRemoveContract     -> TestWriteFSRemove
  TestWriteFSRemoveAllContract  -> TestWriteFSRemoveAll
  TestDirEntriesContract        -> TestDirEntries
  TestWalkFilesContract         -> TestWalkFiles

and each entry point's options struct loses the suffix with it
(WriteFSWriteContract -> WriteFSWrite, and so on), so the struct now reads as
the options for the test that takes it: imptest.TestWalkFiles(t, fsys,
imptest.WalkFiles{...}). Files follow: write_contract.go is write.go, the
backend callers are imptest_test.go, and fs/walk_contract_test.go is
walk_test.go with TestWalkFiles_DirEntriesError.

Prose that said "the contract" now says what it means — the behavior every
implementation must share, what the interface documents, what the suite pins.
The fixture path prefixes move with the package (write-contract/new.txt is
imptest-write/new.txt) so a stray file in a failing test still names its
source.

No assertion, knob, or skip changed. The same three skips stand: local Write
atomicity (#163), s3 Remove of a missing key (#166), and the fileWalk
nil-deref (#165).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnxfAdBrXieiGaAENQ74Hh
doc.go says only that the file is documentation; imptest.go names the package
it introduces, matching the file-per-entry-point naming beside it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnxfAdBrXieiGaAENQ74Hh
WriteDotIsError let a backend opt out of rejecting "." as a write target,
on the theory that a backend without directories might treat it as an
ordinary key. No backend wants that: both callers set it true, and the
one backend the escape hatch was written for — s3, which has no
directories — guards "." explicitly anyway.

Worse, the knob only asserted that the write failed, not how, which let
the local backend pass without a guard of its own: fs.ValidPath accepts
".", so the rejection came from the kernel refusing to open the storage
root for writing. That EISDIR matches neither fs.ErrInvalid nor anything
else a caller can test for, and it would have quietly disappeared under
a write-to-temp-and-rename refactor.

Drop the field, assert errors.Is(err, fs.ErrInvalid) outright, and add
the guard local.FS.Write was missing — matching the s3 backend and the
suite's treatment of every other bad name.
RemoveDotIsNotExist let each backend classify Remove(".") its own way: the s3
backend returned fs.ErrNotExist, the local backend a bare errors.New that
matched nothing. A caller could not recognize the refusal without knowing
which storage it was talking to, which is exactly the disagreement this suite
exists to catch — so the knob was encoding a defect rather than a difference.

fs.ErrNotExist is the wrong half of that pair to standardize on: "." names the
storage root, which is the one path guaranteed to exist. Reporting it absent
invites a caller to conclude there is nothing to clean up. It is a bad name,
not a missing file — the same reading openFile and Write already give it (s3
rejects "." with fs.ErrInvalid in both, and local.FS.Write gained the matching
guard in the previous commit). Both backends now return fs.ErrInvalid, both
render as "remove .: invalid argument", and WriteFS.Remove says so.

RemoveAll(".") is untouched and keeps RemoveAllDotIsError: there the backends
genuinely differ in capability rather than in error text — s3 can empty its
bucket, local cannot remove its own root — so no single answer is available to
standardize on.

Verified by mutation: restoring the s3 fs.ErrNotExist fails the dot subtest.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnxfAdBrXieiGaAENQ74Hh
The Skip-fields example still passed RemoveDotIsNotExist, a field that no
longer exists, and the options-struct section still described a world where
knobs were the norm. Two have since been deleted — WriteDotIsError and
RemoveDotIsNotExist — leaving WriteFSRemoveAll as the only struct with real
knobs, WriteFSWrite and WriteFSRemove carrying nothing but Skip fields, and
TestDirEntries taking no struct at all.

Say that, and say what the two deletions taught: a field that lets each
backend name a different error is usually recording a defect rather than a
difference, and belongs in an assertion once the backends agree. Also
distinguish WalkFiles.ErrWalk, which is a fixture and not a knob — the
assertions about the failure it supplies are the same for every backend — and
name the three skips currently outstanding.

Doc-only; no code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnxfAdBrXieiGaAENQ74Hh
…p fields

The Skip* fields made each caller declare which assertions its backend fails,
threading a reason string through the options struct to reach a t.Skip inside
the suite. That is machinery for something simpler: these subtests are notes
about work to come, not per-backend configuration. Skip them where they are
written, under a TODO naming the issue that closes the gap.

  SkipFailedSourceKeepsFile  ->  TODO(#163) + t.Skip in write.go, x2
  SkipMissingIsNotExist      ->  TODO(#166) + t.Skip in remove.go
  SkipWalkErrors             ->  TODO(#165) + t.Skip in walkfiles.go

With the fields gone, WriteFSWrite and WriteFSRemove hold nothing at all, so
both entry points drop their options parameter and join TestDirEntries in
taking just a backend. WalkFiles keeps ErrWalk: the fixture stays wired up
through the skipped subtest so closing #165 is a one-line change, and its doc
now says so.

The tradeoff, recorded in each TODO: an unconditional skip covers both
backends, and every one of these is one-sided. Local satisfies the missing-key
Remove today and s3 satisfies both Write cases and the walk-error case, so
each skip now gives up a backend that was passing. The TODO names which one,
so the fixing PR knows what it is switching back on.

The package doc's "Skip fields" section is replaced by one describing the
convention, and the options-struct section no longer describes structs that
carry only Skip fields, because none do.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnxfAdBrXieiGaAENQ74Hh
The suite grew a file per method — write.go, remove.go, removeall.go,
direntries.go, walkfiles.go — which split the ocflfs.WriteFS story across
three files when it is one interface with three methods, and left no obvious
home for a new assertion.

Regroup by the interface each file covers:

  writefs.go        TestWriteFSWrite, TestWriteFSRemove, TestWriteFSRemoveAll
  direntriesfs.go   TestDirEntries
  filewalker.go     TestWalkFiles

writefs.go orders the three to match ocflfs.WriteFS's own method order in
fs/fs.go and keeps failingReader at the bottom, below the entry points it
serves. The three merged files had byte-identical import blocks, so the
merge reconciles nothing.

A pure move: no signature, type, assertion, skip or TODO changed, and
neither caller needed an edit. Verified by diffing the -v subtest list
before and after — 68 subtests, identical.

Note for reviewers: git needs -M30% to see writefs.go as a rename of
write.go, since absorbing the other two files drops the similarity below
the default threshold.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JnxfAdBrXieiGaAENQ74Hh
@exe-dev-github-integration

Copy link
Copy Markdown
Author

Review (from Shelley, adversarial pass per repo conventions)

Full suite passes (go test ./..., plus -race on fs/...). Findings ranked by severity.

Critical / contract-level

1. Divergence: Remove(".") is now fs.ErrInvalid per-backend, but package-level ocflfs.RemoveAll(".") still happily empties the rootfs/fs.go:159. This PR pins ". names the storage root, never a file → ErrInvalid" as a universal invariant in the WriteFS.Remove doc, then documents (in imptest.go and the WriteFSRemoveAll knob) that s3 RemoveAll(".") emptying the bucket is "legitimate." That's a defensible capability split, but note the consequence: ocflfs.RemoveAll(ctx, s3fs, ".") bypasses the backend's RemoveAll entirely (special-cased at fs.go:165), so the RemoveAllDotIsError: false knob on s3 only covers direct BucketFS.RemoveAll(".") calls, while library callers get a third, different behavior (entry-by-entry Remove/RemoveAll). Three behaviors for one operation, two of which the suite deliberately doesn't assert. At minimum the package-level carve-out deserves a mention in the WriteFS.RemoveAll doc this PR is already touching.

2. Mock DeleteObject diverges from real S3 in a way that paper-cuts #166fs/s3/internal/mock/mock.go:405. Real S3 DeleteObject on a missing key returns 204 (success); the mock returns NoSuchKey. The issue plans #166 to add a HEAD probe to s3.remove(), after which the contract test asserts ErrNotExist — fine. But the skip-TODO's premise ("s3's remove calls the idempotent DeleteObject") is only true against real S3; against this mock, today's remove() already returns an error for a missing key — an untyped NoSuchKey that fails errors.Is(err, fs.ErrNotExist). So the skipped subtest would fail against the mock for the wrong reason, and a #166 fix that only mapped NoSuchKeyErrNotExist in remove() would turn the mock green without adding the HEAD probe the issue calls for. The mock is modeling the post-#166 world while the backend lives in the pre-#166 one.

3. Mock S3API state (objects, UpdatedETags, Deleted, MPU* flags) is unsynchronized; only the new call log has a mutexmock.go:57-65. The call-log comment claims the log avoids "the mock's MPU mutex" — but no MPU mutex exists in the struct (only parts sync.Map). The s3 backend's multipart uploader and copier issue concurrent UploadPart/PutObject/CompleteMultipartUpload from goroutines; any test writing large files through the mock while another goroutine lists/reads is a data race. -race passes today only because no current test drives the mock concurrently. This PR is the one adding concurrency machinery to the mock; leaving the rest of the struct unguarded makes the "guarded by its own mutex" comment misleading and sets a trap for #166-era tests (a goroutine RemoveAll while asserting Calls()).

Robustness / correctness (non-blocking)

4. GetObject Range slicing aliases the stored obj.Body (mock.go:91-103) — body = obj.Body[start:end+1] shares the backing array; PutObject stores the caller's io.ReadAll result directly. A test that mutates a returned buffer corrupts mock state. Low risk in practice; a one-line bytes.Clone fixes it.

5. fs/walk_test.go and imptest/filewalker.go skip the same #165 defect twice — the nil-deref panic is pinned by both TestWalkFiles_DirEntriesError (fs package, scripted DirEntriesFS) and the imptest ErrWalk subtest. Two skips naming the same issue means the fixing PR must remember to delete both; the walk_test.go version is the stronger test (it also covers Info() errors and continuation-after-error), so consider whether the imptest ErrWalk fixture earns its keep, or at least cross-reference the two.

6. Invalid-path tables never include a nested-but-missing Remove parent (OpenFile on missing-dir/file.txt must also be ErrNotExist per house contract — only bare no-such-file.txt is pinned).

Verified non-issues

Bottom line: #1 and #3 are the ones I'd block on. #1 is a doc/contract coherence question worth answering deliberately; #3 is a latent race in freshly-added concurrency-sensitive test infrastructure. #2 is worth a comment on #166 so the fix doesn't stop at error-mapping.

exe.dev user and others added 2 commits August 25, 2026 18:10
WriteFS.Remove now pins "." as fs.ErrInvalid on every backend, while
WriteFS.RemoveAll leaves "." to the implementation and the package-level
RemoveAll special-cases "." without consulting the backend at all. A
caller hitting any one of the three docs had no way to learn the other
two existed.

Say so in both doc comments: the interface doc names the two permitted
backend behaviors and points root-emptying callers at the package-level
helper; the helper doc notes that the backend behavior it bypasses is
only observable directly.

Doc-only; no code changed.

Co-authored-by: Shelley <shelley@exe.dev>
The call log added a mutex while the rest of the mock — objects,
UpdatedETags, Deleted, the MPU flags — stayed unsynchronized, and its
comment referred to "the mock's MPU mutex", which does not exist. The
s3 backend issues PutObject, UploadPart and friends from the uploader's
goroutines, so any test that drives the mock concurrently while reading
those fields (the shape #166-era assertions will take) is a data race;
-race passed only because no test does that yet.

One mutex now guards objects, UpdatedETags, Deleted and the MPU flags,
with the same discipline the call log already used: parts keeps its
sync.Map and the log its own mutex, and no handler holds the state lock
while touching either, so there is no lock ordering to reason about.

- Handlers lock around map/flag access; ListObjectsV2 holds the lock for
  its whole scan so the listing is a consistent snapshot rather than a
  torn read of a map being written.
- GetObject, CopyObject and UploadPartCopy clone the body bytes they
  hand out or hash, under the lock: a read of a stored slice must not
  race a PutObject replacing it, and a returned buffer must stay valid
  if one does. This also fixes the aliasing where GetObject returned a
  subslice of the stored body a caller could mutate into mock state.
- The exported fields stay for compatibility, with a doc steer to the
  new accessors (WasDeleted, UpdatedETag, MPU*Flag) that take the lock;
  the existing fs_test.go assertions switch to them.
- New TestMockConcurrentUse pins the guarantee: 8 goroutines of
  overlapping puts/gets/heads/lists/deletes, fatal under -race if any
  state access lost its guard.

Co-authored-by: Shelley <shelley@exe.dev>
@srerickson
srerickson merged commit 447f050 into main Aug 25, 2026
1 check passed
@srerickson
srerickson deleted the claude/gh-issue-162-tests-47m89i branch August 25, 2026 18:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

internal/testutil: add cross-backend WriteFS contract test suite

2 participants