Skip to content

Coverage Improvement Analysis — arda-frontend-app

Scenario-based analysis of where the frontend unit-test suite can be strengthened, ranked by payout-per-effort. Produced during Phase 3.6c after arda-frontend-app#742 tripped the unit-tests coverage gate (statements 84.99% < 85%) as a side-effect of removing previously-covered code in ItemFormPanel.tsx (the usingDefaultImage notification block).

The goal of this document is to identify specific test scenarios, not just low-coverage files. Each item states the condition that is not currently exercised, estimates the payout in covered statements, and outlines a cheap test to write.

MetricBefore 3.6cAfter Tier-3 additionsThreshold
Statements84.99% (CI failing)85.03%85%
Branches73.11%73.15%72%
Functions82.00%82.03%81%
Lines86.14%86.16%85%

Margin on statements remains the tightest. Branches is also the weakest dimension relative to threshold and offers the highest regression-catching payout per test (branch-dense code is rarer to exercise accidentally).

Payout estimates are based on the uncovered line ranges reported by npx jest --coverage. Each scenario is scored on three axes:

  1. Payout — statements/branches expected to be covered by the new test(s).
  2. Risk value — how safety-critical the code is (auth, CDN signing, upload pipeline = high; internal UI state = low).
  3. Effort — estimated time to write and land the test.

The ordering below puts payout-per-effort first, with a tie-break on risk value. That intentionally biases toward critical-path code even when it’s slightly more effort to test, because those are the tests most likely to catch real regressions.

These are not speculative “future work” items — they ship as part of the 4.11.6 patch that completes issue 1 (skip cropper on upload) and fixes the empty-blob URL upload bug. They belong in this document because they sit squarely on the image-upload surface that the rest of the tiers reason about, and because the scenarios below are directly usable as a checklist when reviewing the 4.11.6 test additions.

The tests live in ux-prototype (design-system); none of them are in arda-frontend-app. Coverage impact on the consumer is indirect — the fixed component behaves correctly now, reducing the chance of future regressions reaching the consumer in the first place.

A. Direct-upload on drop (completes issue 1)

Section titled “A. Direct-upload on drop (completes issue 1)”

File: src/components/canary/organisms/item-card-editor/item-card-editor.test.tsx.

The rapid-batch UX requires the card’s empty-state drop zone to upload directly without opening the cropper dialog. The following scenarios pin the contract:

#ScenarioAssertion
A.1Drop a file with onUpload suppliedDialog never appears; onUpload called with the file; on resolve, fields.imageUrl commits to returned CDN URL and onImageConfirmed fires once.
A.2Drop a URL with onUploadFromUrl suppliedSame as A.1 but through onUploadFromUrl.
A.3Spinner during uploadWhile onUpload is pending, the drop-zone slot renders a role="status" element with "Uploading image…". Drop zone is not present.
A.4Upload rejectsInline error banner renders with role="alert" containing the rejection message. fields.imageUrl unchanged. onUploadError callback fires with the Error.
A.5"Try again" after errorClicking the Try-again button removes the alert and restores the default drop zone so the user can drop again.
A.6Drop file without onUpload configuredInline error "File upload is not configured"; onUploadError fires with an Error. No call to any upload handler.
A.7Drop URL without onUploadFromUrl configuredInline error "URL upload is not configured"; onUploadError fires.
A.8Error input from drop zoneIgnored silently at the card level; ImageDropZone surfaces validation messages inline itself.
A.9Edit-existing flow unchangedWith fields.imageUrl set, clicking the "Replace image" hover overlay opens the dialog with existingImageUrl. Cropper is reachable through this path.

Payout: ~55 statements + ~15 branches across the rewritten handleDropZoneInput, the three-way render switch in the image slot, and the state machine for uploadState. Also covers the new onUploadFromUrl and onUploadError props.

B. URL-input upload bug in ImageUploadDialog

Section titled “B. URL-input upload bug in ImageUploadDialog”

File: src/components/canary/organisms/shared/image-upload-dialog/image-upload-dialog.test.tsx.

The Uploading effect previously coerced URL inputs to new Blob([]) and called onUpload with 0 bytes, producing a silent 0-byte CDN object and a broken image when anyone later rendered it. The fix routes URL inputs through a new onUploadFromUrl handler or dispatches UPLOAD_ERROR if the host hasn’t supplied one. Scenarios:

#ScenarioAssertion
B.1URL input + onUploadFromUrl present, user clicks ConfirmonUploadFromUrl called with the URL; onConfirm fires with the returned CDN URL. onUpload not called.
B.2URL input + no onUploadFromUrlDialog transitions to UploadError with "URL upload not supported" message. onUpload not called (no silent 0-byte upload). onConfirm not called.
B.3File input + both handlers presentFile takes the onUpload path; onUploadFromUrl never invoked. Confirms backward-compatibility of the file path.

Payout: ~15 statements + ~6 branches. Small numbers, but this closes a silent-data-corruption bug that can’t be caught by the existing tests because they only exercised File input.

  • The accessibility attributes (role="status" for the spinner region, role="alert" for errors) mirror the conventions already used by ImageDropZone, ImageFormField, and ImageUploadDialog’s FailedValidation and UploadError phases. Tests assert on these roles rather than on specific DOM structure, so the underlying markup can change without breaking the tests.
  • The existing pendingInput-entry-point tests from 4.11.5 remain intact. pendingInput is no longer used by ItemCardEditor for the upload path, but it’s kept in the public API for any future consumer that wants the cropper-first-upload UX.

1. CDN signed-URL cache and in-flight deduplication

Section titled “1. CDN signed-URL cache and in-flight deduplication”

File: src/providers/cdn-cookie-provider.tsx — 42.85% statements; uncovered 89-121, 127-132.

The resolveImageUrl cache machinery is a small state machine with five distinct behaviours, none of which are exercised. Scenarios:

#ScenarioAssertion
1.1Cookie mode: identity pass-throughInput URL returned unchanged; getSignedImageUrl never called.
1.2Non-CDN URL (data URI, blob: URL, external HTTPS)Pass-through in signed-URL mode too; no signing attempt.
1.3Cached CDN URL still within expiresAtReturns cached signed URL; getSignedImageUrl not called this time.
1.4Cached CDN URL expiredCache entry deleted; getSignedImageUrl called once; new URL cached with fresh expiresAt.
1.5Two concurrent resolveImageUrl calls for the same URLinflightRef deduplicates: getSignedImageUrl called once; both callers resolve to the same signed URL.
1.6getSignedImageUrl rejects mid-flightBoth callers see the rejection; inflightRef entry cleaned up.
1.7refreshNow() in signed-URL modeClears the cache and the in-flight map; next resolveImageUrl re-signs.
1.8refreshNow() in cookie modeDelegates to refreshCookies.

Payout: ~60 statements + ~12 branches (≈ 0.12% global). Risk value: High — this is the integrity boundary for signed-URL delivery. A broken cache could leak expired URLs; a broken dedupe could cause upstream rate-limit hits. Effort: ~60 min. One test file, a getSignedImageUrl spy, and renderHook or a minimal provider wrapper.

File: src/lib/cloudfront-signer.ts — 61.90% statements; uncovered 84-116.

The actual RSA-signing path is almost entirely untested. Scenarios:

#ScenarioAssertion
2.1Valid key material, reasonable expiryReturned URL parses, has Policy, Signature, Key-Pair-Id query params.
2.2Malformed PEM private keyThrows a descriptive error, not a cryptic crypto stack trace.
2.3Missing CLOUDFRONT_KEY_ID env varThrows with an actionable error message.
2.4Signing different URLs with the same keySignatures differ; policies differ.

Payout: ~30 statements (≈ 0.06% global). Risk value: High — signing bugs produce URLs that CloudFront either rejects (outage) or accepts when they should not (security). Parseable invariants are worth asserting forever. Effort: ~45 min. Use a test-only RSA keypair fixture so the tests do not need real CloudFront key material.

3. AuthInit — checkAuthThunk and mock-mode token decode

Section titled “3. AuthInit — checkAuthThunk and mock-mode token decode”

File: src/store/components/AuthInit.tsx — 66.30% statements; uncovered 46, 52-56, 75-99, 136, 141-145.

Auth bootstrap on app mount. Scenarios:

#ScenarioAssertion
3.1Tokens in localStorage + non-mock mode, checkAuthThunk fulfilledloadAccountDataThunk dispatched with oidcSub extracted from jwtPayload.
3.2Tokens in localStorage + mock modeJWT decoded locally (atob); loadAccountDataThunk dispatched.
3.3Mock mode + malformed tokencatch branch runs; console.warn fires; no dispatch.
3.4checkAuthThunk fulfilled but accountIsLoading is true or accountTenantEId is setGuard short-circuits; loadAccountDataThunk not dispatched.
3.5No tokens in localStorage at allNo dispatch; user is treated as unauthenticated.

Payout: ~25 statements + ~8 branches (≈ 0.05% global). Risk value: High — auth bootstrap regressions are user-visible (sign-in loops, blank dashboards) and hard to catch manually. Effort: ~45 min. Mock checkAuthThunk and loadAccountDataThunk; render AuthInit inside a provider with seeded localStorage.

Tier 2 — Medium payout, product-visible behaviour

Section titled “Tier 2 — Medium payout, product-visible behaviour”

4. AG Grid cell editor lifecycle for the image column

Section titled “4. AG Grid cell editor lifecycle for the image column”

File: src/app/items/ItemTableAGGrid.tsx — 69.68% statements; uncovered 142-148, 214-229, 278-300, 312-334, 418, 432-447.

The FD-20 refactor (image column bypasses AG Grid’s built-in editor and opens a wrapper-scoped ImageUploadDialog) is critical for the items page but sparsely tested.

#ScenarioAssertion
4.1Double-click on image cellDialog opens; existingImageUrl reflects the row's current value.
4.2Enter / F2 on focused image cellSame as 4.1.
4.3Dialog confirm with cropped blobapplyTransaction called; dirty row queued for publish.
4.4Dialog cancelRow unchanged; no transaction.
4.5saveAllDrafts with auth errorAuth-error handler wins; drafts preserved.
4.6Grid ref not yet attachedgridApi null guard prevents crash.

Payout: ~80 statements (≈ 0.16% global). Risk value: Medium-high — this is the main bulk-editing surface for items; a regression would block power users. Effort: ~90 min. Mock AG Grid’s useGridApiRef and a minimal row.

Files: src/store/thunks/accountThunks.ts, src/store/thunks/authThunks.ts, src/store/thunks/receivingThunks.ts, src/store/thunks/scanThunks.ts (all at 94-98% statements, with scattered uncovered rejection branches).

#ScenarioAssertion
5.1loadAccountDataThunk cancellation (condition returning false)Returns early; no state mutation.
5.2receivingThunks network errors at each fetch callRejection propagated as string payload; toast fired.
5.3authThunks.refreshTokens when refresh token is expiredSign-out side-effect; state cleared.

Payout: ~20 statements per file (~80 total, ≈ 0.16% global). Risk value: Medium — thunks are the error-path gateway; rejection handling regressions are user-visible as “silent failures”. Effort: ~30 min per file.

Files: CardStateDisplay.tsx (33.33%), CardsPreviewModalIndividual.tsx (37.50%), InvitationPreviewPanel.tsx (43.24%).

#ScenarioAssertion
6.1CardStateDisplay for each state enum valueCorrect icon + label. it.each over the state enum.
6.2CardsPreviewModalIndividual open/closeModal visible/hidden; individual-card props wired correctly.
6.3InvitationPreviewPanel renders invitation stateDisplays recipient email, status badge, expiry.

Payout: ~20-30 statements each (≈ 0.06-0.1% global cumulatively). Risk value: Low-medium — presentational, regressions are visible at VRT time. Effort: ~15 min each.

These are genuinely cheap tests, each one an easy commit that bumps coverage with effectively zero risk of breakage.

7. PrefixedInput https:// duplication stripper

Section titled “7. PrefixedInput https:// duplication stripper”

File: src/components/items/ItemFormPanel.tsx lines 189-194.

Scenario: User pastes https://https://example.com into a URL-prefixed input. The component strips the duplicate prefix.

Test: Render the input, fire a paste event, assert the resulting onChange payload is example.com.

Payout: 5 statements. Effort: 10 min.

File: src/components/items/ItemFormPanel.tsx lines 364, 393, 414.

Scenario: localStorage.setItem/getItem/removeItem throws (e.g. quota exceeded). The catch block logs and swallows the error.

Test: Use jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('quota') }) once per test; render the panel; trigger save/load/clear; assert console.error called and no crash.

Payout: 6 statements. Effort: 20 min.

File: src/store/slices/accountSlice.ts lines 44-47 (33.33% branches).

Scenario: The reducer is called with an unexpected action payload shape. The guard branch returns state unchanged.

Test: Dispatch the action with undefined payload; assert state reference equals previous state.

Payout: 4 statements + 2 branches. Effort: 10 min.

File: src/store/middleware/tokenRefreshMiddleware.ts lines 29, 37.

Scenario: Middleware is invoked with no token in state, or with a refresh already in flight.

Test: Seed store with no token; dispatch a neutral action; assert the middleware does not schedule a refresh. Then seed “refreshing” flag; repeat.

Payout: 2 statements + 2 branches. Effort: 10 min.

  • Do not chase the biggest uncovered line ranges in DesktopScanView.tsx (71.75%, uncovered 71-242, 276-396, …). That is ~170 lines of JSX rendering scan-page UI. Covering it would require Playwright-scale component tests that are expensive to write and brittle against layout changes. The existing coverage already covers the state-machine core of the scan flow; the uncovered lines are render helpers that are better served by VRT.
  • Do not add tests that only exercise JSX rendering without assertions on behaviour. These add coverage numbers without adding regression safety.
  • Do not try to push global coverage above 90% without widening the test strategy (e.g. component-integration Playwright tests alongside Jest). The remaining uncovered 10-15% is largely error-path and presentational code that unit tests cannot cheaply reach.
  1. To restore margin above the 85% threshold (ought to be done in this PR or a follow-up chore PR): pick any 2-3 items from Tier 3 (§7-§10). ~17 statements covered, ~30 min total work.
  2. To meaningfully raise coverage and catch real regressions: work through Tier 1 items in order. Each is scoped, high-value, and delivers test infrastructure that enables future tests. Adds ~1.5% to global statements coverage and a larger jump in branches.
  3. To cover changed surfaces as they are modified: adopt Tier 2 items opportunistically. Whenever ItemTableAGGrid.tsx is touched, add a cell-editor lifecycle test; whenever a thunk is touched, add a rejection-path test.
  • Coverage threshold stays at 85% for statements. The threshold already tolerates the current state of the codebase. Lowering it would mask regressions. Raising it requires the work in Tiers 1 and 2.
  • Branch coverage is the weakest axis but its threshold (72%) is already tight against current (73.11%). The Tier 1 tests are the best vehicle to raise both at once because cache/guard/auth code is branch-dense.