test: add instrumented tests for Cache and EventEmitterWorker#124
test: add instrumented tests for Cache and EventEmitterWorker#124Fonsecaaso wants to merge 5 commits into
Conversation
- 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>
anonvt
left a comment
There was a problem hiding this comment.
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 correctness —
MockAnalyticsServicenever records which method was called. A bug routing clicks toreportImpression()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
TopsortAnalyticsHttpServiceisinternal— no BCV impact, cleaner than the existingTopsortAuctionsHttpServicepublic pattern- Comprehensive response code coverage (2xx, 4xx, 5xx, exceptions) for all 4 event types
- Correct use of
TestListenableWorkerBuilderfor 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>
Fonsecaaso
left a comment
There was a problem hiding this comment.
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.
anonvt
left a comment
There was a problem hiding this comment.
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:67dropscom.topsort.analytics.workerfrom excludes, butkoverVerifyruns on:TopsortAnalytics:test(JVM unit tests) only, while the new worker coverage lives inandroidTest. 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. -
MockAnalyticsServicedoesn't verify dispatch correctness. All fourreportX()methods return the samemockResponse()— a bug routing clicks toreportImpression()(or vice versa) would pass. This matters because the last year's twoEventEmitterWorkerfixes (#95, #115) were exactly this class of bug. One-line fix: addvar lastMethod: String?and assert it per test. (EventEmitterWorkerTest.kt:325-350) (flagged by both reviewers) -
CacheTestcleanup leavesCache.recentRecordIdlive across tests.@AfterwipesSharedPreferences, but the counter only resets because every@BeforecallsCache.setup(), which re-reads it from prefs. Fragile; a future test that skipssetup()will silently corrupt state. Add a comment, or restructure. (CacheTest.kt:21-32) -
stored_events_persist_after_reinitializedoesn't simulate a restart. Re-callsCache.setup()on the same singleton, reusing in-memoryapplicationContext/preferences. Passes trivially. Rename, or genuinely reset the singleton. (CacheTest.kt:271-286) -
reading_wrong_event_type_throws_or_returns_mismatched_dataasserts implementation, not contract. Test name says "OR" but only verifies the throw branch. Pins currentfromJsonbehavior 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 areinternal. CLAUDE.md says "internal by default" — unify. AlsogetRandomPageViewWithContextis 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-39withvar service+@VisibleForTestingsetters is strictly cleaner than theTopsortAuctionsHttpServiceobject-level annotation, and fine for the currentinternalconsumer. But: it cements static global dispatch inEventEmitterWorker, 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 forWorkerFactory+ constructor injection. Consider doing that now; at minimum markservice@Volatileand comment the test-only mutability invariant. Do not expand this pattern to additional services without revisiting. -
EventEmitterWorkerTestis instrumented but shouldn't need to be.CacheTestgenuinely 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 totest/with Robolectric would resolve the Kover concern above.
Followups
- Migrate
EventEmitterWorkerTestto Robolectric unit tests; re-add the worker Kover exclusion until that lands (or verify coverage stays above 35%). - Retrofit
TopsortAuctionsHttpServiceto 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() → entriesswap 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 (entriesis preferred but there's no@Deprecated).
Nits
doWork_impression_nonexistent_returns_successhas no Click/Purchase/PageView equivalent — same code path, either add three or drop this one.different_event_types_get_unique_record_idsasserts strict ordering on top ofsetOf(...).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 plaininternal) is a meaningful improvement over the auction service's object-level annotation; move the convention in this direction.- Ordinal guard at
EventEmitterWorker.kt:36closes a realArrayIndexOutOfBoundsException, 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>
There was a problem hiding this comment.
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) WorkerFactoryinjection (tracked for batching phase)@Volatile+ thread-safety comment onTopsortAnalyticsHttpService.service- Retrofit
TopsortAuctionsHttpServiceto this PR's cleaner pattern
anonvt
left a comment
There was a problem hiding this comment.
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
-
EventEmitterWorkerTestdoesn'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 returnedrecordIdand the "nonexistent" tests use hardcoded high IDs (999996–999999) to dodge collisions — accidental isolation, not real isolation. MirrorCacheTest's@After: clear bothTOPSORT_EVENTS_CACHEandTOPSORT_EVENTS_CACHE_ENCRYPTED. -
reading_wrong_event_type_throwsasserts on the bareExceptionsuperclass (CacheTest.kt:272). This passes for any failure mode — NPE, future JSON-lib swap, OOM — and actively blocks a saner future contract (e.g.readClickreturning null on type mismatch would still pass because NPEs satisfyException::class). Tighten to the actual type thrown, likelyJSONException. -
TestObjectsAndroid.ktvisibility inconsistency (TestObjectsAndroid.kt:108-122). The PR adds explicitinternalto existing helpers and togetTestPageViewEvent, but leavesgetTestImpressionEvent,getTestClickEvent,getTestPurchaseEventas default-public — and they all returninternalmodel types, so they'republicfunctions returninginternaltypes. CLAUDE.md says "internal by default." Make all fourinternal.
Design notes
-
Mock-injection seam is a global mutable
varon a singleton, not a dependency. Worker still callsTopsortAnalyticsHttpService.service.reportImpression(...)per request. The architectural fit is constructor injection viaWorkerFactory— production stays a singleton, tests pass aServicedirectly intoTestListenableWorkerBuilder.setWorkerFactory(...). The current pattern is parallel-test-unsafe (last@Beforewins), and accepting it codifies the precedent for future services. A@Volatileon thevarwould 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)inEventEmitterWorker.doWork()is outside the per-eventtry/catch— only the HTTP call is wrapped. If a record-id is ever enqueued with the wrongEXTRA_EVENT_TYPE(corruption, mid-store kill, future refactor),doWork()throws an uncheckedJSONException, 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 withreadEventreturning null on missing keys), or (b) the worker's read is wrapped to convert parse failures toResult.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 siblingTopsortAuctionsHttpService, which ispublicand leakssetMockService/setServiceInstanceintoTopsortAnalytics.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.gradlestill excludescom.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 inEventEmitterWorkerwon't fail Kover. Track an issue to either Robolectric-ize the worker (so JVM-Kover sees it) or add an instrumented-coverage threshold. - Sibling
TopsortAuctionsHttpServiceBCV leak (TopsortAnalytics.api:985-991): addinternal, 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) soCache.readClick(impressionId)returns null instead of throwing.
Strengths
- Keeping
TopsortAnalyticsHttpServiceinternalwithprivate setis 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.sizebounds check plugs a realArrayIndexOutOfBoundsExceptionand is paired with a test. Small, defensive, correctly scoped.
Dual-reviewed by a senior engineer subagent and an architect subagent.
Summary
Add comprehensive instrumented tests for the two most critical untested components:
CacheandEventEmitterWorker. These tests address the main test coverage gap identified in the SDK evaluation.Changes
New Tests
CacheTest.kt (22 tests)
EventEmitterWorkerTest.kt (19 tests)
Code Changes
TopsortAnalyticsHttpService.kt
setMockService()andresetToDefaultService()methodsTopsortAuctionsHttpServiceTestObjectsAndroid.kt
getRandomPageView()andgetRandomPageViewWithContext()helpersbuild.gradle
com.topsort.analytics.workerfrom Kover exclusions (now tested)Test Coverage Impact
Test Plan
TestListenableWorkerBuilder)TopsortAuctionsHttpService🤖 Generated with Claude Code