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.
Current state
Section titled “Current state”| Metric | Before 3.6c | After Tier-3 additions | Threshold |
|---|---|---|---|
| Statements | 84.99% (CI failing) | 85.03% | 85% |
| Branches | 73.11% | 73.15% | 72% |
| Functions | 82.00% | 82.03% | 81% |
| Lines | 86.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).
Ranking philosophy
Section titled “Ranking philosophy”Payout estimates are based on the uncovered line ranges reported by
npx jest --coverage. Each scenario is scored on three axes:
- Payout — statements/branches expected to be covered by the new test(s).
- Risk value — how safety-critical the code is (auth, CDN signing, upload pipeline = high; internal UI state = low).
- 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.
Tests added in 4.11.6 (image-upload path)
Section titled “Tests added in 4.11.6 (image-upload path)”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:
| # | Scenario | Assertion |
|---|---|---|
| A.1 | Drop a file with onUpload supplied | Dialog never appears; onUpload called with the file; on resolve, fields.imageUrl commits to returned CDN URL and onImageConfirmed fires once. |
| A.2 | Drop a URL with onUploadFromUrl supplied | Same as A.1 but through onUploadFromUrl. |
| A.3 | Spinner during upload | While onUpload is pending, the drop-zone slot renders a role="status" element with "Uploading image…". Drop zone is not present. |
| A.4 | Upload rejects | Inline error banner renders with role="alert" containing the rejection message. fields.imageUrl unchanged. onUploadError callback fires with the Error. |
| A.5 | "Try again" after error | Clicking the Try-again button removes the alert and restores the default drop zone so the user can drop again. |
| A.6 | Drop file without onUpload configured | Inline error "File upload is not configured"; onUploadError fires with an Error. No call to any upload handler. |
| A.7 | Drop URL without onUploadFromUrl configured | Inline error "URL upload is not configured"; onUploadError fires. |
| A.8 | Error input from drop zone | Ignored silently at the card level; ImageDropZone surfaces validation messages inline itself. |
| A.9 | Edit-existing flow unchanged | With 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:
| # | Scenario | Assertion |
|---|---|---|
| B.1 | URL input + onUploadFromUrl present, user clicks Confirm | onUploadFromUrl called with the URL; onConfirm fires with the returned CDN URL. onUpload not called. |
| B.2 | URL input + no onUploadFromUrl | Dialog transitions to UploadError with "URL upload not supported" message. onUpload not called (no silent 0-byte upload). onConfirm not called. |
| B.3 | File input + both handlers present | File 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.
Cross-cutting notes
Section titled “Cross-cutting notes”- The accessibility attributes (
role="status"for the spinner region,role="alert"for errors) mirror the conventions already used byImageDropZone,ImageFormField, andImageUploadDialog’sFailedValidationandUploadErrorphases. 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.pendingInputis no longer used byItemCardEditorfor the upload path, but it’s kept in the public API for any future consumer that wants the cropper-first-upload UX.
Tier 1 — High payout, critical path
Section titled “Tier 1 — High payout, critical path”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:
| # | Scenario | Assertion |
|---|---|---|
| 1.1 | Cookie mode: identity pass-through | Input URL returned unchanged; getSignedImageUrl never called. |
| 1.2 | Non-CDN URL (data URI, blob: URL, external HTTPS) | Pass-through in signed-URL mode too; no signing attempt. |
| 1.3 | Cached CDN URL still within expiresAt | Returns cached signed URL; getSignedImageUrl not called this time. |
| 1.4 | Cached CDN URL expired | Cache entry deleted; getSignedImageUrl called once; new URL cached with fresh expiresAt. |
| 1.5 | Two concurrent resolveImageUrl calls for the same URL | inflightRef deduplicates: getSignedImageUrl called once; both callers resolve to the same signed URL. |
| 1.6 | getSignedImageUrl rejects mid-flight | Both callers see the rejection; inflightRef entry cleaned up. |
| 1.7 | refreshNow() in signed-URL mode | Clears the cache and the in-flight map; next resolveImageUrl re-signs. |
| 1.8 | refreshNow() in cookie mode | Delegates 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.
2. CloudFront URL signer
Section titled “2. CloudFront URL signer”File: src/lib/cloudfront-signer.ts — 61.90% statements;
uncovered 84-116.
The actual RSA-signing path is almost entirely untested. Scenarios:
| # | Scenario | Assertion |
|---|---|---|
| 2.1 | Valid key material, reasonable expiry | Returned URL parses, has Policy, Signature, Key-Pair-Id query params. |
| 2.2 | Malformed PEM private key | Throws a descriptive error, not a cryptic crypto stack trace. |
| 2.3 | Missing CLOUDFRONT_KEY_ID env var | Throws with an actionable error message. |
| 2.4 | Signing different URLs with the same key | Signatures 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:
| # | Scenario | Assertion |
|---|---|---|
| 3.1 | Tokens in localStorage + non-mock mode, checkAuthThunk fulfilled | loadAccountDataThunk dispatched with oidcSub extracted from jwtPayload. |
| 3.2 | Tokens in localStorage + mock mode | JWT decoded locally (atob); loadAccountDataThunk dispatched. |
| 3.3 | Mock mode + malformed token | catch branch runs; console.warn fires; no dispatch. |
| 3.4 | checkAuthThunk fulfilled but accountIsLoading is true or accountTenantEId is set | Guard short-circuits; loadAccountDataThunk not dispatched. |
| 3.5 | No tokens in localStorage at all | No 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.
| # | Scenario | Assertion |
|---|---|---|
| 4.1 | Double-click on image cell | Dialog opens; existingImageUrl reflects the row's current value. |
| 4.2 | Enter / F2 on focused image cell | Same as 4.1. |
| 4.3 | Dialog confirm with cropped blob | applyTransaction called; dirty row queued for publish. |
| 4.4 | Dialog cancel | Row unchanged; no transaction. |
| 4.5 | saveAllDrafts with auth error | Auth-error handler wins; drafts preserved. |
| 4.6 | Grid ref not yet attached | gridApi 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.
5. Thunk rejection paths
Section titled “5. Thunk rejection paths”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).
| # | Scenario | Assertion |
|---|---|---|
| 5.1 | loadAccountDataThunk cancellation (condition returning false) | Returns early; no state mutation. |
| 5.2 | receivingThunks network errors at each fetch call | Rejection propagated as string payload; toast fired. |
| 5.3 | authThunks.refreshTokens when refresh token is expired | Sign-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.
6. Small presentational components
Section titled “6. Small presentational components”Files: CardStateDisplay.tsx (33.33%), CardsPreviewModalIndividual.tsx
(37.50%), InvitationPreviewPanel.tsx (43.24%).
| # | Scenario | Assertion |
|---|---|---|
| 6.1 | CardStateDisplay for each state enum value | Correct icon + label. it.each over the state enum. |
| 6.2 | CardsPreviewModalIndividual open/close | Modal visible/hidden; individual-card props wired correctly. |
| 6.3 | InvitationPreviewPanel renders invitation state | Displays 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.
Tier 3 — Easy single-assertion wins
Section titled “Tier 3 — Easy single-assertion wins”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.
8. localStorage error branches
Section titled “8. localStorage error branches”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.
9. accountSlice reducer edge case
Section titled “9. accountSlice reducer edge case”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.
10. tokenRefreshMiddleware guard branches
Section titled “10. tokenRefreshMiddleware guard branches”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.
Anti-patterns to avoid
Section titled “Anti-patterns to avoid”- 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.
Suggested sequencing
Section titled “Suggested sequencing”- 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.
- 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.
- To cover changed surfaces as they are modified: adopt Tier 2
items opportunistically. Whenever
ItemTableAGGrid.tsxis touched, add a cell-editor lifecycle test; whenever a thunk is touched, add a rejection-path test.
Decision log
Section titled “Decision log”- 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.
Copyright: © Arda Systems 2025-2026, All rights reserved