fix(enrich): add comic search path to bulk EnrichWorkflow (bookshelf-vnu3) #884

Merged
zombor merged 6 commits from bd-bookshelf-vnu3 into main 2026-07-03 00:18:53 +00:00
Owner

Summary

  • Bug: Bulk EnrichWorkflow searched comics as ebooks. RefetchMetadata always built an ebook MatchQuery (Title/Author/ISBN), so ComicVine received a title-only query and returned no comic metadata. Book 68530 ("The Invincible Red Sonja #7") showed COMPLETED state but comic_metadata was never written.
  • Fix: Detect CBX comic books in RefetchMetadata via a new is_comic field in GetBookMetadataForRefetch. When IsComic && series != "", build a structured comic MatchQuery (Series + IssueNumber + Year using the same fallback chain as the interactive Fetch Metadata form) and restrict providers to those with "comic-issue" capability (ComicVine, Metron).
  • Metron added: Metron is now in the buildEnrichDeps provider pool (with metronBulkInterval rate limit) so comic bulk enrich can use it alongside ComicVine.
  • No regression: Non-comic books keep identical ebook query + all-provider behavior. Comics with no resolvable series fall back to the ebook query path.

Changes

  • internal/db/queries/metadata.sql + internal/db/sqlc/metadata.sql.go: Extend GetBookMetadataForRefetch to LEFT JOIN comic_metadata and add is_comic EXISTS subquery + 4 comic fields.
  • internal/books/metadata_store.go: Add IsComic, ComicVolumeName, ComicIssueNumber, ComicCoverDate, PrimaryComicFileName to MetadataRow; map them in GetMetadataForRefetch.
  • internal/books/metadata_service.go: Add ComicRefetchSearchInputs(MetadataRow) (exported, tested) implementing the volume_name→series_name→filename fallback chain. Update RefetchMetadata to detect comics and build a comic query with capability-filtered providers.
  • internal/app/build_extended_deps.go: Add Metron to allProviders in buildEnrichDeps.
  • Tests: metadata_service_test.go + metadata_store_test.go with full coverage of the new paths.

Test plan

  • make test green
  • make coverage green (check-coverage: OK — zero uncovered statement blocks)
  • make build clean
  • CI green

Closes bead bookshelf-vnu3 on merge.

## Summary - **Bug:** Bulk `EnrichWorkflow` searched comics as ebooks. `RefetchMetadata` always built an ebook `MatchQuery` (Title/Author/ISBN), so ComicVine received a title-only query and returned no comic metadata. Book 68530 ("The Invincible Red Sonja #7") showed COMPLETED state but `comic_metadata` was never written. - **Fix:** Detect CBX comic books in `RefetchMetadata` via a new `is_comic` field in `GetBookMetadataForRefetch`. When `IsComic && series != ""`, build a structured comic `MatchQuery` (Series + IssueNumber + Year using the same fallback chain as the interactive Fetch Metadata form) and restrict providers to those with `"comic-issue"` capability (ComicVine, Metron). - **Metron added:** `Metron` is now in the `buildEnrichDeps` provider pool (with `metronBulkInterval` rate limit) so comic bulk enrich can use it alongside ComicVine. - **No regression:** Non-comic books keep identical ebook query + all-provider behavior. Comics with no resolvable series fall back to the ebook query path. ## Changes - `internal/db/queries/metadata.sql` + `internal/db/sqlc/metadata.sql.go`: Extend `GetBookMetadataForRefetch` to LEFT JOIN `comic_metadata` and add `is_comic` EXISTS subquery + 4 comic fields. - `internal/books/metadata_store.go`: Add `IsComic`, `ComicVolumeName`, `ComicIssueNumber`, `ComicCoverDate`, `PrimaryComicFileName` to `MetadataRow`; map them in `GetMetadataForRefetch`. - `internal/books/metadata_service.go`: Add `ComicRefetchSearchInputs(MetadataRow)` (exported, tested) implementing the volume_name→series_name→filename fallback chain. Update `RefetchMetadata` to detect comics and build a comic query with capability-filtered providers. - `internal/app/build_extended_deps.go`: Add Metron to `allProviders` in `buildEnrichDeps`. - Tests: `metadata_service_test.go` + `metadata_store_test.go` with full coverage of the new paths. ## Test plan - [x] `make test` green - [x] `make coverage` green (check-coverage: OK — zero uncovered statement blocks) - [x] `make build` clean - [ ] CI green Closes bead bookshelf-vnu3 on merge.
fix(enrich): add comic search path to bulk EnrichWorkflow
Some checks failed
/ JS Unit Tests (pull_request) Successful in 57s
/ E2E Browser (pull_request) Successful in 2m35s
/ E2E API (pull_request) Successful in 3m53s
/ Lint (pull_request) Failing after 4m1s
/ Integration (pull_request) Successful in 4m41s
/ Test (pull_request) Successful in 5m24s
aaaa36d6f9
The bulk EnrichWorkflow called books.RefetchMetadata which always built
an ebook MatchQuery (Title/Author/ISBN), so comics like 'The Invincible
Red Sonja #7' were searched with a title-only query that ComicVine
cannot answer. The result was COMPLETED workflow with no comic metadata
written.

Changes:
- Extend GetBookMetadataForRefetch SQL query to LEFT JOIN comic_metadata
  and add an EXISTS subquery for is_comic (CBX book_file present). Five
  new columns: is_comic, comic_volume_name, comic_issue_number,
  comic_cover_date, primary_comic_file_name.
- Add the corresponding fields to MetadataRow and map them in
  GetMetadataForRefetch.
- Add ComicRefetchSearchInputs(MetadataRow) implementing the same
  volume_name→series_name→filename fallback chain as comicSearchPrefill.
- In RefetchMetadata, when existing.IsComic is true and a series name
  can be resolved, build a comic MatchQuery (Series+IssueNumber+Year)
  and restrict activeProviders to those with the "comic-issue" capability
  (ComicVine, Metron) so ebook providers are not called with a
  Series-only query.
- Add Metron to allProviders in buildEnrichDeps (with metronBulkInterval
  rate limit) so it is available for comic bulk enrich alongside
  ComicVine.

Non-comic books keep identical ebook MatchQuery and all-provider
behavior unchanged. Comics with no resolvable series fall back to the
ebook query path.

Closes bead bookshelf-vnu3 on merge.

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/18e4f775-bc31-40d7-9fad-5e9fcd231c40)
fix(sqlc): fix GetBookMetadataForRefetchRow alignment + PrimaryComicFileName type
Some checks failed
/ Lint (pull_request) Successful in 3m9s
/ E2E API (pull_request) Failing after 3m12s
/ Integration (pull_request) Successful in 3m19s
/ JS Unit Tests (pull_request) Successful in 27s
/ E2E Browser (pull_request) Failing after 3m47s
/ Test (pull_request) Successful in 4m4s
7f297a26c2
sqlc re-aligns all struct fields to accommodate the longest field name
(PrimaryComicFileName = 20 chars). Also, sqlc infers string (not
sql.NullString) for the scalar subquery because book_file.file_name is
NOT NULL. Update the store and test stub accordingly.

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/19044f73-9e7a-42cc-9a58-93e90fbd8328)

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/deff1b92-9971-49a4-a777-8e3ed56fe48d)
fix(sqlc): COALESCE primary_comic_file_name to prevent NULL scan into string
Some checks failed
/ JS Unit Tests (pull_request) Successful in 42s
/ E2E API (pull_request) Successful in 2m4s
/ Lint (pull_request) Failing after 2m13s
/ E2E Browser (pull_request) Successful in 2m36s
/ Integration (pull_request) Successful in 2m50s
/ Test (pull_request) Successful in 3m34s
af8b0532bb
Scanning SQL NULL into a plain Go string (*string) causes an error in
database/sql. For books with no CBX files the subquery returns NULL,
breaking GetBookMetadataForRefetch for all ebook books and causing HTTP
500 errors in the metadata refetch handler.

Add COALESCE(..., '') so primary_comic_file_name is always a non-empty
string when present or empty string when absent. sqlc infers string (NOT
NULL) which matches the existing struct field type.

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/bf3fc468-13ca-44d1-b4ea-d327182be227)

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/02408fcc-c877-46a8-b8b8-7976f9958896)
fix(vnu3): move primary_comic_file_name to separate GetFirstCBXFileNameForBook query
Some checks failed
/ JS Unit Tests (pull_request) Successful in 36s
/ E2E API (pull_request) Successful in 2m50s
/ Lint (pull_request) Failing after 3m11s
/ Integration (pull_request) Successful in 3m54s
/ Test (pull_request) Successful in 4m39s
/ E2E Browser (pull_request) Successful in 3m47s
9c57373b09
COALESCE in the scalar subquery caused sqlc to generate interface{} instead of
string, breaking the struct type. Move the CBX filename lookup to a dedicated
GetFirstCBXFileNameForBook :one query that returns sql.ErrNoRows when no CBX
file exists, avoiding the NULL-scan problem entirely.

GetMetadataForRefetch gains a getFirstCBXFileName func dep (5th arg) and calls
it conditionally when row.IsComic=true. Warn-logs real DB errors; ignores
ErrNoRows (expected for non-comic paths).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/eabb07e6-6d1d-4de7-bd4a-8e04db9f3ecd)

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/f3c617d4-73d2-48c0-ab9c-fc812bbffb90)
fix(vnu3): place GetFirstCBXFileNameForBook in alphabetical order in generated file
All checks were successful
/ JS Unit Tests (pull_request) Successful in 41s
/ E2E API (pull_request) Successful in 1m29s
/ Lint (pull_request) Successful in 2m35s
/ Integration (pull_request) Successful in 2m45s
/ E2E Browser (pull_request) Successful in 3m14s
/ Test (pull_request) Successful in 3m37s
059b21d278
sqlc generates functions in alphabetical order not SQL-file order. GetFirstCBXFileNameForBook
('GetF') belongs after GetCategoryByName ('GetC') and before RefreshSortAuthorName ('Ref')
not after GetBookMetadataForRefetch or at end of file.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/5bd1a55e-7fc0-4bd6-ac35-1f2752e49483)

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/b6cd552f-ae18-4f0b-84b5-fe7ccf8e6615)
Author
Owner

Security Review — PR #884 (bookshelf-vnu3)

Focus: comic enrich path — filename parsing, Metron credentials, SQL safety, outbound injection, multi-user, architecture boundary.


No blockers. No majors. Three minor test-convention findings.


[MINOR] internal/books/metadata_service_test.go:214 — Multiple Expect calls per It in ComicRefetchSearchInputs tests
Each Context/It block for ComicRefetchSearchInputs (lines ~221-227, ~237-241, ~250-253) contains setup, invocation, and 2-3 Expect calls inside a single It block. Project convention (project-conventions.md) requires exactly one Expect per It with setup in BeforeEach and invocation in JustBeforeEach. Fix: hoist the row construction into BeforeEach, the call into JustBeforeEach, and split into separate It blocks per assertion.

[MINOR] internal/books/metadata_service_test.go:459 — "no series fallback" test mixes setup/invocation/assertions in one It
The Describe("RefetchMetadata comic book with no series fallback") block constructs the refetch function, calls it, and asserts three things (err, Title, Series) all inside a single It. This violates the BeforeEach/JustBeforeEach separation and the one-Expect-per-It rule. Fix: refactor to standard BeforeEach/JustBeforeEach/It structure with individual assertion Its.

[MINOR] internal/books/metadata_service_test.go:368 — Standalone It("returns no error") on the comic happy path should be folded
Line 368 has a bare It("returns no error") { Expect(err).NotTo(HaveOccurred()) } in the happy-path Describe. Convention says to fold the nil-error check into the value assertion (Expect(result, err).To(...)) rather than a standalone block. The bool result from RefetchMetadata (didUpdate) gives a natural fold target: Expect(didUpdate, err).To(BeTrue()).


Security surface — all clean:

  • Path traversal: PrimaryComicFileName is fetched from the DB and passed only to stripFileExt (pure string, stops at '/') and parseComicFilenameParts (regex on the stem). No os.Open, filepath.Join, or any filesystem operation on the value anywhere in the diff. Safe.
  • SQL injection: Both new queries (GetFirstCBXFileNameForBook, modified GetBookMetadataForRefetch) are fully parameterized via sqlc with ? placeholders. No string-concatenated SQL.
  • Outbound query injection: The series/issue/year values reach Metron via url.Values.Set() + .Encode() in buildSearchURL (metron/provider.go:392-398), which URL-encodes all values. No bypass path in the diff.
  • Metron credentials: Fetched dynamically via GetProviderCredentials from app_settings. Not logged anywhere in the diff; used only via req.SetBasicAuth() in doRequest. No hardcoding.
  • Multi-user: Enrich is book-scoped (book_id). No per-user data path introduced. getFirstCBXFileName scopes by book_id only. No new user-facing endpoint.
  • Architecture boundary: internal/books/metadata_service.go and metadata_store.go import only stdlib + internal/metadata + internal/middleware + internal/settings + internal/db/sqlc. No go-workflows import. Clean.
  • Log hygiene: The comic-query detection log (metadata_service.go:155-162) logs book_id, series, issue, year, trace_id — all from book metadata or filename, no credentials or PII.
  • Resource/DoS: GetFirstCBXFileNameForBook has LIMIT 1. The correlated EXISTS subquery in GetBookMetadataForRefetch is bounded by book_id = ?. No unbounded fan-out introduced.
  • Test package: Both metadata_service_test.go and metadata_store_test.go correctly declare package books_test (black-box).

REVIEW VERDICT: 0 blocker, 0 major, 3 minor

## Security Review — PR #884 (bookshelf-vnu3) **Focus:** comic enrich path — filename parsing, Metron credentials, SQL safety, outbound injection, multi-user, architecture boundary. --- No blockers. No majors. Three minor test-convention findings. --- [MINOR] internal/books/metadata_service_test.go:214 — Multiple Expect calls per It in ComicRefetchSearchInputs tests Each Context/It block for ComicRefetchSearchInputs (lines ~221-227, ~237-241, ~250-253) contains setup, invocation, and 2-3 Expect calls inside a single It block. Project convention (project-conventions.md) requires exactly one Expect per It with setup in BeforeEach and invocation in JustBeforeEach. Fix: hoist the row construction into BeforeEach, the call into JustBeforeEach, and split into separate It blocks per assertion. [MINOR] internal/books/metadata_service_test.go:459 — "no series fallback" test mixes setup/invocation/assertions in one It The Describe("RefetchMetadata comic book with no series fallback") block constructs the refetch function, calls it, and asserts three things (err, Title, Series) all inside a single It. This violates the BeforeEach/JustBeforeEach separation and the one-Expect-per-It rule. Fix: refactor to standard BeforeEach/JustBeforeEach/It structure with individual assertion Its. [MINOR] internal/books/metadata_service_test.go:368 — Standalone It("returns no error") on the comic happy path should be folded Line 368 has a bare It("returns no error") { Expect(err).NotTo(HaveOccurred()) } in the happy-path Describe. Convention says to fold the nil-error check into the value assertion (Expect(result, err).To(...)) rather than a standalone block. The bool result from RefetchMetadata (didUpdate) gives a natural fold target: Expect(didUpdate, err).To(BeTrue()). --- **Security surface — all clean:** - **Path traversal:** PrimaryComicFileName is fetched from the DB and passed only to stripFileExt (pure string, stops at '/') and parseComicFilenameParts (regex on the stem). No os.Open, filepath.Join, or any filesystem operation on the value anywhere in the diff. Safe. - **SQL injection:** Both new queries (GetFirstCBXFileNameForBook, modified GetBookMetadataForRefetch) are fully parameterized via sqlc with ? placeholders. No string-concatenated SQL. - **Outbound query injection:** The series/issue/year values reach Metron via url.Values.Set() + .Encode() in buildSearchURL (metron/provider.go:392-398), which URL-encodes all values. No bypass path in the diff. - **Metron credentials:** Fetched dynamically via GetProviderCredentials from app_settings. Not logged anywhere in the diff; used only via req.SetBasicAuth() in doRequest. No hardcoding. - **Multi-user:** Enrich is book-scoped (book_id). No per-user data path introduced. getFirstCBXFileName scopes by book_id only. No new user-facing endpoint. - **Architecture boundary:** internal/books/metadata_service.go and metadata_store.go import only stdlib + internal/metadata + internal/middleware + internal/settings + internal/db/sqlc. No go-workflows import. Clean. - **Log hygiene:** The comic-query detection log (metadata_service.go:155-162) logs book_id, series, issue, year, trace_id — all from book metadata or filename, no credentials or PII. - **Resource/DoS:** GetFirstCBXFileNameForBook has LIMIT 1. The correlated EXISTS subquery in GetBookMetadataForRefetch is bounded by book_id = ?. No unbounded fan-out introduced. - **Test package:** Both metadata_service_test.go and metadata_store_test.go correctly declare package books_test (black-box). REVIEW VERDICT: 0 blocker, 0 major, 3 minor
Author
Owner

CODE REVIEW: NOT APPROVED

Phase 0: DEMO Verification

No DEMO block found in bd show bookshelf-vnu3 or bd comments bookshelf-vnu3. For a background workflow fix a live DEMO is acknowledged as impractical (requires full docker stack + populated DB + workflow trigger). Proceeded on the strength of the unit test coverage and green CI, but flagging the absence per review standard.


Phase 1: Spec Compliance

All five spec requirements are present in the diff (is_comic detection, comic MatchQuery, Metron wiring, persist routing, tests). However, requirement 3 has a correctness gap — see MAJOR below.


Phase 2: Code Quality

[MAJOR] internal/books/metadata_service.go:887 — Metron is called for every non-comic (ebook/audiobook) book in bulk enrich
The provider-capability filter is one-directional:

if isComicQuery && !containsString(np.Capabilities, "comic-issue") {
    continue  // blocks ebook providers for comic queries only
}

When isComicQuery == false (ebooks, audiobooks), the && short-circuits and the guard never fires. Metron (capabilities ["comic-issue", "series"]) passes through into activeProviders for every ebook. For a 250 k-book library with 10% comics, each bulk enrich sweep sends ~225 k needless requests to Metron. Those requests burn the metronBulkInterval rate-limit budget that was explicitly set to leave headroom for interactive fetches, defeating the intent. Metron will return ErrNoMatch (so no data is corrupted), but the rate-limit drain is real.
Fix: add the symmetric guard — skip comic-only providers for ebook queries:

if !isComicQuery && containsString(np.Capabilities, "comic-issue") &&
    !containsString(np.Capabilities, "isbn") && !containsString(np.Capabilities, "title") {
    continue
}

(The existing capability vocabulary already supports this: ebook providers carry isbn/title/author; Metron carries only comic-issue/series.)

[MINOR] internal/books/metadata_service.go:248 — stripFileExt duplicates existing extension-stripping logic
primaryComicFileStem in comic_search_prefill.go:74 already strips the extension using strings.TrimSuffix(f.FileName, filepath.Ext(f.FileName)). The new hand-rolled loop (stripFileExt) does the same thing with an extra / boundary guard that is unused in practice (DB file_name is a bare filename, not a path). Extract and reuse filepath.Ext + strings.TrimSuffix, or expose a shared helper, to avoid two implementations diverging.

[MINOR] internal/books/metadata_service_test.go:~1718 — last Describe violates one-Expect-per-It and BeforeEach/JustBeforeEach/It separation
Describe("RefetchMetadata comic book with no series fallback") puts setup, invocation (_, err = refetch(ctx, 1, nil, nil)), and three Expect calls all inside a single It block. Per project conventions, setup belongs in BeforeEach, the call under test in JustBeforeEach, and each It holds exactly one Expect. The other new test blocks follow the pattern correctly; this one regresses.


REVIEW VERDICT: 0 blocker, 1 major, 2 minor

## CODE REVIEW: NOT APPROVED ### Phase 0: DEMO Verification No DEMO block found in `bd show bookshelf-vnu3` or `bd comments bookshelf-vnu3`. For a background workflow fix a live DEMO is acknowledged as impractical (requires full docker stack + populated DB + workflow trigger). Proceeded on the strength of the unit test coverage and green CI, but flagging the absence per review standard. --- ### Phase 1: Spec Compliance All five spec requirements are present in the diff (is_comic detection, comic MatchQuery, Metron wiring, persist routing, tests). However, requirement 3 has a correctness gap — see MAJOR below. --- ### Phase 2: Code Quality [MAJOR] internal/books/metadata_service.go:887 — Metron is called for every non-comic (ebook/audiobook) book in bulk enrich The provider-capability filter is one-directional: ```go if isComicQuery && !containsString(np.Capabilities, "comic-issue") { continue // blocks ebook providers for comic queries only } ``` When `isComicQuery == false` (ebooks, audiobooks), the `&&` short-circuits and the guard never fires. Metron (capabilities `["comic-issue", "series"]`) passes through into `activeProviders` for every ebook. For a 250 k-book library with 10% comics, each bulk enrich sweep sends ~225 k needless requests to Metron. Those requests burn the `metronBulkInterval` rate-limit budget that was explicitly set to leave headroom for interactive fetches, defeating the intent. Metron will return ErrNoMatch (so no data is corrupted), but the rate-limit drain is real. Fix: add the symmetric guard — skip comic-only providers for ebook queries: ```go if !isComicQuery && containsString(np.Capabilities, "comic-issue") && !containsString(np.Capabilities, "isbn") && !containsString(np.Capabilities, "title") { continue } ``` (The existing capability vocabulary already supports this: ebook providers carry `isbn`/`title`/`author`; Metron carries only `comic-issue`/`series`.) [MINOR] internal/books/metadata_service.go:248 — `stripFileExt` duplicates existing extension-stripping logic `primaryComicFileStem` in `comic_search_prefill.go:74` already strips the extension using `strings.TrimSuffix(f.FileName, filepath.Ext(f.FileName))`. The new hand-rolled loop (`stripFileExt`) does the same thing with an extra `/` boundary guard that is unused in practice (DB `file_name` is a bare filename, not a path). Extract and reuse `filepath.Ext` + `strings.TrimSuffix`, or expose a shared helper, to avoid two implementations diverging. [MINOR] internal/books/metadata_service_test.go:~1718 — last Describe violates one-Expect-per-It and BeforeEach/JustBeforeEach/It separation `Describe("RefetchMetadata comic book with no series fallback")` puts setup, invocation (`_, err = refetch(ctx, 1, nil, nil)`), and three `Expect` calls all inside a single `It` block. Per project conventions, setup belongs in `BeforeEach`, the call under test in `JustBeforeEach`, and each `It` holds exactly one `Expect`. The other new test blocks follow the pattern correctly; this one regresses. --- REVIEW VERDICT: 0 blocker, 1 major, 2 minor
fix(books): symmetric capability guard + test hygiene (bookshelf-vnu3)
All checks were successful
/ E2E API (pull_request) Successful in 2m19s
/ Lint (pull_request) Successful in 3m28s
/ JS Unit Tests (pull_request) Successful in 1m10s
/ Integration (pull_request) Successful in 3m31s
/ E2E Browser (pull_request) Successful in 4m7s
/ Test (pull_request) Successful in 4m23s
2e7a536fe5
- Add symmetric capability filter: non-comic books skip providers that have
  "comic-issue" but none of "isbn"/"title"/"author" (Metron, ComicVine)
- Remove stripFileExt duplication; inline strings.TrimSuffix+filepath.Ext
- Fix test hygiene in ComicRefetchSearchInputs: BeforeEach/JustBeforeEach,
  one Expect per It
- Fix RefetchMetadata comic path: fold standalone no-error It, split
  2-Expect It into separate Its
- Fix RefetchMetadata non-comic path: hoist vars, split 2-Expect It
- Fix RefetchMetadata comic book with no series fallback: proper
  BeforeEach/JustBeforeEach/It structure
- Add capability filtering Describe block with tests for ebook exclusion
  of Metron and comic inclusion of Metron

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Workflow Detail page screenshot (wf-detail-older-execution)

Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent.

wf-detail-older-execution

**Workflow Detail page screenshot** (wf-detail-older-execution) Older completed ContinueAsNew epoch detail — execution ID and state visible, Cancel absent. ![wf-detail-older-execution](/attachments/bd9ce62f-7c45-4af5-a425-8fc09c052443)

Recompute Match Score — kebab open screenshot (recompute-match-score-kebab-open)

recompute-match-score-kebab-open

**Recompute Match Score — kebab open screenshot** (recompute-match-score-kebab-open) ![recompute-match-score-kebab-open](/attachments/f54da76e-6cec-44b2-bbcc-ccdff66ae288)
zombor merged commit 1da5490a8c into main 2026-07-03 00:18:53 +00:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
zombor/pergamum!884
No description provided.