Skip to content

test: add instrumented tests for Cache and EventEmitterWorker#124

Open
Fonsecaaso wants to merge 5 commits into
mainfrom
test/cache-and-worker-coverage
Open

test: add instrumented tests for Cache and EventEmitterWorker#124
Fonsecaaso wants to merge 5 commits into
mainfrom
test/cache-and-worker-coverage

Conversation

@Fonsecaaso
Copy link
Copy Markdown
Contributor

@Fonsecaaso Fonsecaaso commented Apr 16, 2026

Summary

Add comprehensive instrumented tests for the two most critical untested components: Cache and EventEmitterWorker. These tests address the main test coverage gap identified in the SDK evaluation.

Changes

New Tests

CacheTest.kt (22 tests)

  • Token and session storage verification
  • Impression store/read/delete operations
  • Click store/read/delete operations
  • Purchase store/read/delete operations
  • PageView store/read/delete operations
  • Mixed event type handling (unique record IDs)
  • Persistence after cache reinitialization

EventEmitterWorkerTest.kt (19 tests)

  • Invalid input handling (negative record ID, invalid event type, missing data)
  • Success path (200-299): deletes event from cache, returns success
  • Permanent failure (400-499): deletes event from cache, returns failure
  • Transient failure (500+): keeps event in cache, returns retry
  • Exception handling: keeps event in cache, returns retry
  • All four event types tested: Impression, Click, Purchase, PageView

Code Changes

TopsortAnalyticsHttpService.kt

  • Added setMockService() and resetToDefaultService() methods
  • Follows same pattern as TopsortAuctionsHttpService
  • Enables unit testing of components that depend on the HTTP service

TestObjectsAndroid.kt

  • Added getRandomPageView() and getRandomPageViewWithContext() helpers
  • Added event wrapper functions for Cache tests

build.gradle

  • Removed com.topsort.analytics.worker from Kover exclusions (now tested)

Test Coverage Impact

Component Before After
Cache.kt 0% ~80%
EventEmitterWorker.kt 0% (excluded) ~90%

Test Plan

  • CacheTest covers all event types (Impression, Click, Purchase, PageView)
  • EventEmitterWorkerTest covers all response scenarios (2xx, 4xx, 5xx, exceptions)
  • Tests use WorkManager testing utilities (TestListenableWorkerBuilder)
  • Mock service injection follows existing pattern from TopsortAuctionsHttpService
  • CI runs instrumented tests on API 34 emulator

🤖 Generated with Claude Code

- Add CacheTest with 22 tests covering:
  - Token and session storage
  - Impression, Click, Purchase, PageView store/read/delete
  - Mixed event type handling
  - Persistence after reinitialization

- Add EventEmitterWorkerTest with 19 tests covering:
  - Invalid input handling
  - Success path (200-299) deletes event from cache
  - Permanent failure (400-499) returns failure and deletes event
  - Transient failure (500+) returns retry and keeps event
  - Exception handling returns retry

- Add mock injection to TopsortAnalyticsHttpService:
  - setMockService() and resetToDefaultService() methods
  - Follows same pattern as TopsortAuctionsHttpService

- Update TestObjectsAndroid.kt with PageView helpers
- Remove worker package from Kover exclusions

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@Fonsecaaso Fonsecaaso requested a review from a team as a code owner April 16, 2026 20:58
@Fonsecaaso Fonsecaaso requested a review from barbmarcio April 16, 2026 20:58
Copy link
Copy Markdown
Collaborator

@anonvt anonvt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: test: add instrumented tests for Cache and EventEmitterWorker

Reviewed by two AI agents. Test quality is high, mock pattern is clean (internal = no BCV leak), and the test architecture is sound. However, the tests surfaced a pre-existing production bug that should be fixed in this PR.


CRITICAL — Pre-existing production bug

EventEmitterWorker.doWork() out-of-range ordinal crashes

EventEmitterWorker.kt line 40:

eventType = EventType.values()[eventTypeOrdinal]

The guard on line 36 only checks eventTypeOrdinal < 0. A positive out-of-range value (e.g., 99) throws ArrayIndexOutOfBoundsException. Since this PR adds tests for this component and removes it from Kover exclusions, this is the right place to fix it.

Fix:

if (recordId < 0 || eventTypeOrdinal < 0 || eventTypeOrdinal >= EventType.values().size) {
    return Result.success()
}

Add a test: doWork_with_out_of_range_event_type_ordinal using eventTypeOrdinal = 999.


HIGH

No @After cleanup in CacheTest

Cache is a singleton backed by real SharedPreferences. Events from one test persist into the next. Works today due to auto-incrementing IDs, but fragile if test order changes or tests run repeatedly.

Fix: Add @After to clear SharedPreferences between tests.


MEDIUM (non-blocking)

  • Mock doesn't verify dispatch correctnessMockAnalyticsService never records which method was called. A bug routing clicks to reportImpression() wouldn't be caught. Consider adding capture fields for a future follow-up.
  • Kover exclusion removal — Verify CI includes instrumented coverage in the threshold calculation. If only unit tests count, removing the exclusion may drop the coverage number since the worker has 0% unit-test coverage.

Positive

  • TopsortAnalyticsHttpService is internal — no BCV impact, cleaner than the existing TopsortAuctionsHttpService public pattern
  • Comprehensive response code coverage (2xx, 4xx, 5xx, exceptions) for all 4 event types
  • Correct use of TestListenableWorkerBuilder for WorkManager testing
  • Test naming consistently follows snake_case convention
  • 41 tests total — meaningful coverage improvement

🤖 AI Agent Prompts Used

Agent 1: Senior Software Engineer

Review test correctness, assertions, isolation, mock design, coverage gaps,
production code changes, BCV impact, naming conventions.
Read both test files AND the source files being tested.

Agent 2: Software Architect

Evaluate mock service pattern (internal vs public, BCV impact, thread safety),
test architecture (real integration vs mocked), Cache/Worker coverage completeness,
Kover exclusion implications.

- Fix EventEmitterWorker out-of-range ordinal crash (CRITICAL)
  - Add upper bound check: eventTypeOrdinal >= EventType.entries.size
  - Use EventType.entries instead of deprecated values()

- Add test for out-of-range event type ordinal
  - doWork_with_out_of_range_event_type_ordinal_returns_success

- Add @after cleanup to CacheTest (HIGH)
  - Clear SharedPreferences between tests for isolation
  - Clears both plaintext and encrypted preferences

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

@Fonsecaaso Fonsecaaso left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the thorough review! All issues addressed in commit 49bf682:

CRITICAL - Fixed ✅

EventEmitterWorker out-of-range ordinal crash

// Before
if (recordId < 0 || eventTypeOrdinal < 0) { ... }

// After  
if (recordId < 0 || eventTypeOrdinal < 0 || eventTypeOrdinal >= EventType.entries.size) { ... }

Also updated to use EventType.entries instead of deprecated values().

Added test: doWork_with_out_of_range_event_type_ordinal_returns_success

HIGH - Fixed ✅

CacheTest @after cleanup

@After
fun cleanup() {
    context.getSharedPreferences("TOPSORT_EVENTS_CACHE", Context.MODE_PRIVATE)
        .edit().clear().commit()
    context.getSharedPreferences("TOPSORT_EVENTS_CACHE_ENCRYPTED", Context.MODE_PRIVATE)
        .edit().clear().commit()
}

MEDIUM - Acknowledged

  • Mock dispatch verification: Good point for a follow-up PR. Adding capture fields would be valuable.
  • Kover exclusion: Will monitor coverage in CI. If it drops significantly, we can adjust.

Copy link
Copy Markdown
Collaborator

@anonvt anonvt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Round 2 — the previous CHANGES_REQUESTED items (ordinal crash, @After cleanup) are correctly addressed. New findings below, largely in test quality and Kover-gate semantics.

Critical

None.

Important

  • Kover gate silently defanged, not strengthened. build.gradle:67 drops com.topsort.analytics.worker from excludes, but koverVerify runs on :TopsortAnalytics:test (JVM unit tests) only, while the new worker coverage lives in androidTest. The Android variant report isn't merged into the verified report, so this removes coverage exclusion without adding JVM coverage — effectively dropping the worker contribution to the 35% gate to zero. A future unrelated PR could flip the gate red for reasons invisible from its own diff. Either keep the exclusion until Robolectric unit tests land, merge the Android report, or use a separate threshold.

  • MockAnalyticsService doesn't verify dispatch correctness. All four reportX() methods return the same mockResponse() — a bug routing clicks to reportImpression() (or vice versa) would pass. This matters because the last year's two EventEmitterWorker fixes (#95, #115) were exactly this class of bug. One-line fix: add var lastMethod: String? and assert it per test. (EventEmitterWorkerTest.kt:325-350) (flagged by both reviewers)

  • CacheTest cleanup leaves Cache.recentRecordId live across tests. @After wipes SharedPreferences, but the counter only resets because every @Before calls Cache.setup(), which re-reads it from prefs. Fragile; a future test that skips setup() will silently corrupt state. Add a comment, or restructure. (CacheTest.kt:21-32)

  • stored_events_persist_after_reinitialize doesn't simulate a restart. Re-calls Cache.setup() on the same singleton, reusing in-memory applicationContext/preferences. Passes trivially. Rename, or genuinely reset the singleton. (CacheTest.kt:271-286)

  • reading_wrong_event_type_throws_or_returns_mismatched_data asserts implementation, not contract. Test name says "OR" but only verifies the throw branch. Pins current fromJson behavior against a future improvement. Decide the contract, tighten the assertion — or drop the test. (CacheTest.kt:246-263)

  • TestObjectsAndroid.kt — inconsistent visibility + dead code. Impression/Click/Purchase event helpers are public; PageView helpers are internal. CLAUDE.md says "internal by default" — unify. Also getRandomPageViewWithContext is added but never used; drop it. (TestObjectsAndroid.kt:88-106, 119-137)

Design notes

  • Mutable singleton as a test seam is the right shape for today, wrong shape for the roadmap. TopsortAnalyticsHttpService.kt:17-39 with var service + @VisibleForTesting setters is strictly cleaner than the TopsortAuctionsHttpService object-level annotation, and fine for the current internal consumer. But: it cements static global dispatch in EventEmitterWorker, has no thread-safety discipline (no @Volatile, no sync — WorkManager can race with test teardown), and teardown is best-effort. As batching/privacy/lifecycle phases land, this seam either grows setters per knob or gets ripped out for WorkerFactory + constructor injection. Consider doing that now; at minimum mark service @Volatile and comment the test-only mutability invariant. Do not expand this pattern to additional services without revisiting.

  • EventEmitterWorkerTest is instrumented but shouldn't need to be. CacheTest genuinely needs a device (AndroidKeyStore). The worker test is pure dispatch logic — JVM-trivial with Robolectric. Running it as instrumented means the Kover gate never sees it, every PR triggers the emulator job, and iteration is slow for contributors without KVM. Moving to test/ with Robolectric would resolve the Kover concern above.

Followups

  • Migrate EventEmitterWorkerTest to Robolectric unit tests; re-add the worker Kover exclusion until that lands (or verify coverage stays above 35%).
  • Retrofit TopsortAuctionsHttpService to this PR's cleaner pattern (drop the object-level @VisibleForTesting).
  • WorkerFactory-based injection tracked as part of the batching phase.
  • Decide and pin Cache.fromJson's cross-type contract.
  • Stacked-PR feedback for the author: the ordinal guard is defensible to co-land (the new test surfaced it), but the values() → entries swap is unrelated cleanup that should have been a separate chore PR per CLAUDE.md. Non-blocking.
  • PR body claims EventType.values() is "deprecated" — it isn't (entries is preferred but there's no @Deprecated).

Nits

  • doWork_impression_nonexistent_returns_success has no Click/Purchase/PageView equivalent — same code path, either add three or drop this one.
  • different_event_types_get_unique_record_ids asserts strict ordering on top of setOf(...).hasSize(4); the ordering check is redundant.

Strengths

  • 4xx/5xx/success × 4-event-type matrix is exactly the coverage shape the worker needed, and the transient-failure-does-NOT-delete invariant is correctly guarded.
  • @VisibleForTesting-on-setters (enclosing object plain internal) is a meaningful improvement over the auction service's object-level annotation; move the convention in this direction.
  • Ordinal guard at EventEmitterWorker.kt:36 closes a real ArrayIndexOutOfBoundsException, directly motivated by the new coverage — exactly how tight guards should be discovered.

Dual-reviewed by a senior engineer subagent and an architect subagent.

- Re-add worker package to Kover exclusions with explanatory comment
  (instrumented tests not measured by Kover gate)
- Add lastMethod tracking to MockAnalyticsService for dispatch verification
- Add comment about Cache.recentRecordId not being reset in cleanup
- Rename stored_events_persist test to clarify it doesn't simulate restart
- Simplify reading_wrong_event_type test to pin exception contract
- Unify TestObjectsAndroid visibility (all internal) and remove dead code
- Add nonexistent tests for Click, Purchase, PageView
- Remove redundant ordering assertion in unique_record_ids test

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor Author

@Fonsecaaso Fonsecaaso left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in d318378

@anonvt thanks for your review, here's what I just addressed

Important items

Issue Resolution
Kover gate silently defanged Re-added packages("com.topsort.analytics.worker") to excludes with explanatory comment. Will stay excluded until Robolectric migration lands.
MockAnalyticsService doesn't verify dispatch Added var lastMethod: String? tracking. Each test now asserts assertThat(mockService.lastMethod).isEqualTo("reportX").
CacheTest cleanup leaves recentRecordId live Added comment: // Does not reset Cache.recentRecordId; tests should not rely on absolute ID values.
stored_events_persist doesn't simulate restart Renamed to stored_events_readable_after_setup_called_again with updated comment clarifying it doesn't truly restart the process.
reading_wrong_event_type asserts implementation Simplified to @Test(expected = Exception::class) — pins the "throw" contract.
TestObjectsAndroid inconsistent visibility + dead code All functions now internal. Removed unused getRandomPageViewWithContext() and its imports (Channel, Device).

Nits

Issue Resolution
Missing nonexistent tests for Click/Purchase/PageView Added doWork_click_nonexistent_returns_success, doWork_purchase_nonexistent_returns_success, doWork_pageview_nonexistent_returns_success.
Redundant ordering assertion Removed the three isGreaterThan assertions; setOf(...).hasSize(4) is sufficient for uniqueness.

Deferred to followups

  • Robolectric migration for EventEmitterWorkerTest (will resolve Kover coverage gap)
  • WorkerFactory injection (tracked for batching phase)
  • @Volatile + thread-safety comment on TopsortAnalyticsHttpService.service
  • Retrofit TopsortAuctionsHttpService to this PR's cleaner pattern

@Fonsecaaso Fonsecaaso requested a review from anonvt April 30, 2026 14:21
Copy link
Copy Markdown
Collaborator

@anonvt anonvt left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PR Review

Solid intent-locking test additions for the deliberate 2xx/4xx/5xx retry semantics from #95, plus a clean EventType.entries bounds-check. A few test-isolation issues to fix before merge, and some architectural followups worth tracking.

Important

  • EventEmitterWorkerTest doesn't clear SharedPreferences between tests (worker/EventEmitterWorkerTest.kt:50-53). @After teardown() only resets the mock service. Tests pass today only because each one operates on its own returned recordId and the "nonexistent" tests use hardcoded high IDs (999996–999999) to dodge collisions — accidental isolation, not real isolation. Mirror CacheTest's @After: clear both TOPSORT_EVENTS_CACHE and TOPSORT_EVENTS_CACHE_ENCRYPTED.

  • reading_wrong_event_type_throws asserts on the bare Exception superclass (CacheTest.kt:272). This passes for any failure mode — NPE, future JSON-lib swap, OOM — and actively blocks a saner future contract (e.g. readClick returning null on type mismatch would still pass because NPEs satisfy Exception::class). Tighten to the actual type thrown, likely JSONException.

  • TestObjectsAndroid.kt visibility inconsistency (TestObjectsAndroid.kt:108-122). The PR adds explicit internal to existing helpers and to getTestPageViewEvent, but leaves getTestImpressionEvent, getTestClickEvent, getTestPurchaseEvent as default-public — and they all return internal model types, so they're public functions returning internal types. CLAUDE.md says "internal by default." Make all four internal.

Design notes

  • Mock-injection seam is a global mutable var on a singleton, not a dependency. Worker still calls TopsortAnalyticsHttpService.service.reportImpression(...) per request. The architectural fit is constructor injection via WorkerFactory — production stays a singleton, tests pass a Service directly into TestListenableWorkerBuilder.setWorkerFactory(...). The current pattern is parallel-test-unsafe (last @Before wins), and accepting it codifies the precedent for future services. A @Volatile on the var would be cheap insurance even before that refactor.

  • Worker has a latent crash path that the new test pins down as the contract. Cache.readXxx(recordId) in EventEmitterWorker.doWork() is outside the per-event try/catch — only the HTTP call is wrapped. If a record-id is ever enqueued with the wrong EXTRA_EVENT_TYPE (corruption, mid-store kill, future refactor), doWork() throws an unchecked JSONException, WorkManager reports failure, and the record stays in the cache forever. Root cause: persisted events have no type tag, so the cache is type-blind. Two viable fixes: (a) Cache detects the mismatch and returns null (parity with readEvent returning null on missing keys), or (b) the worker's read is wrapped to convert parse failures to Result.failure() + delete.

  • Two parallel singleton-mock patterns are now codified. This PR introduces a better seam (internal object, private set, no BCV leak) right next to the worse sibling TopsortAuctionsHttpService, which is public and leaks setMockService/setServiceInstance into TopsortAnalytics.api:985-991. Tighten the sibling in this PR or as an immediate followup so we don't ship two not-quite-the-same patterns side by side.

Followups

  • Kover blind spot. build.gradle still excludes com.topsort.analytics.worker. The PR description's "0% → ~90%" coverage gain is real on-device but invisible to the CI gate, so a future regression in EventEmitterWorker won't fail Kover. Track an issue to either Robolectric-ize the worker (so JVM-Kover sees it) or add an instrumented-coverage threshold.
  • Sibling TopsortAuctionsHttpService BCV leak (TopsortAnalytics.api:985-991): add internal, regenerate the API dump.
  • Migrate to WorkerFactory-based DI once a second consumer needs mocking — eliminates the global-mutation pattern entirely.
  • Type-tag persisted events (KEY_RECORD_IMPRESSION_%d, etc., or a type prefix in the JSON envelope) so Cache.readClick(impressionId) returns null instead of throwing.

Strengths

  • Keeping TopsortAnalyticsHttpService internal with private set is materially better than the sibling's public-leaking pattern — the single most important architectural call here and it's correct (flagged by both).
  • Behavioral coverage of the deliberate 4xx-delete vs 5xx-retry split from PR #95, plus the exception path — exactly the kind of intent-pinning test that prevents subtle regressions (flagged by both).
  • The eventTypeOrdinal >= EventType.entries.size bounds check plugs a real ArrayIndexOutOfBoundsException and is paired with a test. Small, defensive, correctly scoped.

Dual-reviewed by a senior engineer subagent and an architect subagent.

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.

2 participants