fix: test infrastructure and entitlement logic updates

This commit is contained in:
null 2026-06-17 21:08:13 -05:00
parent 827cfd2e2d
commit 84390a48fc
8 changed files with 4013 additions and 25 deletions

View File

@ -124,4 +124,9 @@ dependencies {
// Debug // Debug
debugImplementation("androidx.compose.ui:ui-tooling") debugImplementation("androidx.compose.ui:ui-tooling")
debugImplementation("androidx.compose.ui:ui-test-manifest") debugImplementation("androidx.compose.ui:ui-test-manifest")
// Unit tests — JVM only (no device/emulator required)
testImplementation("junit:junit:4.13.2")
testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.9.0")
testImplementation("io.mockk:mockk:1.13.14")
} }

View File

@ -0,0 +1,62 @@
package app.closer.data.repository
import app.closer.data.remote.FirestoreDateMatchDataSource
import app.closer.data.remote.FirestoreDateSwipeDataSource
import app.closer.domain.model.DateSwipe
import app.closer.domain.model.SwipeAction
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.mockk
import io.mockk.slot
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class DateMatchRepositoryImplTest {
private val swipeDataSource: FirestoreDateSwipeDataSource = mockk(relaxed = true)
private val matchDataSource: FirestoreDateMatchDataSource = mockk(relaxed = true)
private val repository = DateMatchRepositoryImpl(swipeDataSource, matchDataSource)
@Test
fun `recordSwipe records via data source and always returns null match`() = runTest {
val swipeSlot = slot<DateSwipe>()
coEvery { swipeDataSource.recordSwipe("couple1", capture(swipeSlot)) } returns Unit
val result = repository.recordSwipe("couple1", "userA", "idea42", SwipeAction.LOVE)
assertTrue(result.isSuccess)
// Match creation is server-side; the client call always returns null.
assertNull(result.getOrNull())
coVerify(exactly = 1) { swipeDataSource.recordSwipe("couple1", any()) }
assertTrue(swipeSlot.captured.userId == "userA")
assertTrue(swipeSlot.captured.dateIdeaId == "idea42")
assertTrue(swipeSlot.captured.action == SwipeAction.LOVE)
}
@Test
fun `recordSwipe with SKIP still records and returns null`() = runTest {
val result = repository.recordSwipe("couple1", "userA", "idea1", SwipeAction.SKIP)
assertTrue(result.isSuccess)
assertNull(result.getOrNull())
coVerify(exactly = 1) { swipeDataSource.recordSwipe(any(), any()) }
}
@Test
fun `recordSwipe propagates data source failure as Result failure`() = runTest {
coEvery { swipeDataSource.recordSwipe(any(), any()) } throws RuntimeException("Firestore unavailable")
val result = repository.recordSwipe("couple1", "userA", "idea1", SwipeAction.LOVE)
assertTrue(result.isFailure)
assertTrue(result.exceptionOrNull() is RuntimeException)
}
@Test
fun `getDateIdeas returns non-empty seed catalog with valid ids`() = runTest {
val ideas = repository.getDateIdeas()
assertTrue("Seed catalog must not be empty", ideas.isNotEmpty())
assertTrue("All ideas must have non-blank ids", ideas.all { it.id.isNotBlank() })
}
}

View File

@ -0,0 +1,180 @@
package app.closer.data.repository
import app.closer.domain.model.LocalAnswer
import app.closer.domain.repository.LocalAnswerRepository
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
/**
* Tests the answer save/reveal state machine via [FakeLocalAnswerRepository].
*
* [SharedPreferencesLocalAnswerRepository] depends on Android's EncryptedSharedPreferences
* and cannot run in a JVM unit test. [FakeLocalAnswerRepository] mirrors the identical
* business logic without the platform dependency, letting us verify:
* - save creates / updates answers
* - save preserves `createdAt` and `isRevealed` on re-save (critical invariant)
* - `markRevealed` transitions isRevealed false true without mutating other fields
* - `delete` removes the answer
*/
class LocalAnswerStateTest {
private lateinit var repo: FakeLocalAnswerRepository
@Before
fun setup() {
repo = FakeLocalAnswerRepository()
}
@Test
fun `saveAnswer stores a new answer`() = runTest {
repo.saveAnswer(answer("q1"))
assertNotNull(repo.getAnswer("q1"))
}
@Test
fun `saveAnswer preserves createdAt on re-save`() = runTest {
repo.saveAnswer(answer("q1", createdAt = 1000L))
// Second save with a different createdAt — must keep the original
repo.saveAnswer(answer("q1", createdAt = 9999L))
assertEquals(1000L, repo.getAnswer("q1")?.createdAt)
}
@Test
fun `saveAnswer preserves isRevealed flag on re-save`() = runTest {
repo.saveAnswer(answer("q1"))
repo.markRevealed("q1")
assertTrue(repo.getAnswer("q1")?.isRevealed == true)
// Re-saving the same answer must not reset the revealed flag
repo.saveAnswer(answer("q1", isRevealed = false))
assertTrue(
"isRevealed must survive a re-save",
repo.getAnswer("q1")?.isRevealed == true
)
}
@Test
fun `saveAnswer updates written text on re-save`() = runTest {
repo.saveAnswer(answer("q1", writtenText = "first draft"))
repo.saveAnswer(answer("q1", writtenText = "revised"))
assertEquals("revised", repo.getAnswer("q1")?.writtenText)
}
@Test
fun `markRevealed transitions answer to revealed`() = runTest {
repo.saveAnswer(answer("q1", isRevealed = false))
assertFalse(repo.getAnswer("q1")?.isRevealed == true)
repo.markRevealed("q1")
assertTrue(repo.getAnswer("q1")?.isRevealed == true)
}
@Test
fun `markRevealed on non-existent question is a no-op`() = runTest {
// Should not throw
repo.markRevealed("nonexistent")
assertNull(repo.getAnswer("nonexistent"))
}
@Test
fun `deleteAnswer removes the answer`() = runTest {
repo.saveAnswer(answer("q1"))
assertNotNull(repo.getAnswer("q1"))
repo.deleteAnswer("q1")
assertNull(repo.getAnswer("q1"))
}
@Test
fun `deleteAnswer on non-existent question is a no-op`() = runTest {
repo.deleteAnswer("nonexistent") // must not throw
}
@Test
fun `observeAnswer emits updated state after save and reveal`() = runTest {
assertNull(repo.getAnswer("q2"))
repo.saveAnswer(answer("q2"))
val saved = repo.getAnswer("q2")
assertNotNull(saved)
assertFalse(saved?.isRevealed == true)
repo.markRevealed("q2")
assertTrue(repo.getAnswer("q2")?.isRevealed == true)
}
// ── helpers ──────────────────────────────────────────────────────────────
private fun answer(
questionId: String,
isRevealed: Boolean = false,
createdAt: Long = 1_000L,
writtenText: String = "Some answer"
) = LocalAnswer(
questionId = questionId,
questionText = "Question $questionId",
category = "test",
answerType = "written",
writtenText = writtenText,
createdAt = createdAt,
isRevealed = isRevealed
)
}
// ── Test double ───────────────────────────────────────────────────────────────
/**
* In-memory [LocalAnswerRepository] that mirrors the exact save/reveal algorithm
* of [SharedPreferencesLocalAnswerRepository] without requiring Android platform APIs.
*
* Any change to the real repository's logic should be reflected here too.
*/
class FakeLocalAnswerRepository : LocalAnswerRepository {
private val _answers = MutableStateFlow<List<LocalAnswer>>(emptyList())
override fun observeAnswers(): Flow<List<LocalAnswer>> = _answers
override fun observeAnswer(questionId: String): Flow<LocalAnswer?> =
_answers.map { list -> list.firstOrNull { it.questionId == questionId } }
override suspend fun getAnswer(questionId: String): LocalAnswer? =
_answers.value.firstOrNull { it.questionId == questionId }
override suspend fun saveAnswer(answer: LocalAnswer) {
val existing = _answers.value.firstOrNull { it.questionId == answer.questionId }
val saved = answer.copy(
createdAt = existing?.createdAt ?: answer.createdAt,
updatedAt = System.currentTimeMillis(),
isRevealed = existing?.isRevealed ?: answer.isRevealed
)
_answers.value = _answers.value
.filterNot { it.questionId == saved.questionId }
.plus(saved)
.sortedByDescending { it.updatedAt }
}
override suspend fun markRevealed(questionId: String) {
_answers.value = _answers.value.map { answer ->
if (answer.questionId == questionId) {
answer.copy(isRevealed = true, updatedAt = System.currentTimeMillis())
} else {
answer
}
}
}
override suspend fun deleteAnswer(questionId: String) {
_answers.value = _answers.value.filterNot { it.questionId == questionId }
}
}

16
functions/jest.config.js Normal file
View File

@ -0,0 +1,16 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
roots: ['<rootDir>/src'],
testMatch: ['**/*.test.ts'],
transform: {
'^.+\\.ts$': ['ts-jest', {
tsconfig: {
// Relax strictness for test files — mocks won't perfectly satisfy production types
noUnusedLocals: false,
strict: false,
},
}],
},
};

File diff suppressed because it is too large Load Diff

View File

@ -8,6 +8,7 @@
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {
"build": "tsc", "build": "tsc",
"test": "jest",
"serve": "npm run build && firebase emulators:start --only functions", "serve": "npm run build && firebase emulators:start --only functions",
"shell": "npm run build && firebase functions:shell", "shell": "npm run build && firebase functions:shell",
"start": "npm run shell", "start": "npm run shell",
@ -20,6 +21,9 @@
"google-auth-library": "^9.0.0" "google-auth-library": "^9.0.0"
}, },
"devDependencies": { "devDependencies": {
"@types/jest": "^29.5.14",
"jest": "^29.7.0",
"ts-jest": "^29.4.11",
"typescript": "^5.4.5" "typescript": "^5.4.5"
} }
} }

View File

@ -0,0 +1,182 @@
// Mock firebase-admin before any module under test is imported.
// `admin.firestore` must work as both a callable (admin.firestore()) and a
// namespace (admin.firestore.Timestamp). Object.assign achieves both.
jest.mock('firebase-admin', () => {
const firestoreFn: any = jest.fn();
firestoreFn.Timestamp = {
now: jest.fn(() => ({ toMillis: () => 1_000_000_000_000 })),
fromMillis: jest.fn((ms: number) => ({ toMillis: () => ms })),
};
return {
firestore: firestoreFn,
apps: [] as any[],
initializeApp: jest.fn(),
};
});
import * as admin from 'firebase-admin';
import {
isPremiumEntitlement,
PREMIUM_ACTIVE_TYPES,
PREMIUM_REVOKED_TYPES,
applyEntitlementEvent,
EntitlementEvent,
} from './entitlementLogic';
// ── Helpers ───────────────────────────────────────────────────────────────────
function event(overrides: Partial<EntitlementEvent> = {}): EntitlementEvent {
return {
id: 'evt-001',
type: 'INITIAL_PURCHASE',
app_user_id: 'user-123',
product_id: 'closer_premium_monthly',
entitlement_id: 'closer_premium',
...overrides,
};
}
function buildFirestoreMock(opts: { createShouldFail?: boolean } = {}) {
const mockSet = jest.fn().mockResolvedValue(undefined);
const mockCreate = jest.fn().mockImplementation(() =>
opts.createShouldFail
? Promise.reject(Object.assign(new Error('ALREADY_EXISTS'), { code: 6 }))
: Promise.resolve(undefined)
);
// Leaf doc ref (e.g. users/{uid}/entitlements/premium)
const deepDocRef = { set: mockSet };
const deepDocFn = jest.fn().mockReturnValue(deepDocRef);
const subCollectionRef = { doc: deepDocFn };
const subCollectionFn = jest.fn().mockReturnValue(subCollectionRef);
// Top-level doc ref: supports create (idempotency marker) and subcollection access
const topDocRef = { create: mockCreate, set: mockSet, collection: subCollectionFn };
const mockDoc = jest.fn().mockReturnValue(topDocRef);
const mockCollection = jest.fn().mockReturnValue({ doc: mockDoc });
const mockInstance = { collection: mockCollection };
(admin.firestore as unknown as jest.Mock).mockReturnValue(mockInstance);
return { mockSet, mockCreate };
}
// ── isPremiumEntitlement ──────────────────────────────────────────────────────
describe('isPremiumEntitlement', () => {
it('returns true when entitlement_id matches', () => {
expect(isPremiumEntitlement(event({ entitlement_id: 'closer_premium' }))).toBe(true);
});
it('returns true when entitlement_ids array includes the id', () => {
expect(
isPremiumEntitlement(event({ entitlement_id: undefined, entitlement_ids: ['closer_premium', 'other'] }))
).toBe(true);
});
it('returns false for a different entitlement_id', () => {
expect(
isPremiumEntitlement(event({ entitlement_id: 'other_entitlement', entitlement_ids: [] }))
).toBe(false);
});
it('returns false when neither id nor ids reference the premium entitlement', () => {
expect(
isPremiumEntitlement(event({ entitlement_id: undefined, entitlement_ids: ['not_premium'] }))
).toBe(false);
});
});
// ── Event type sets ───────────────────────────────────────────────────────────
describe('PREMIUM_ACTIVE_TYPES', () => {
it.each(['INITIAL_PURCHASE', 'RENEWAL', 'PRODUCT_CHANGE', 'TRANSFER', 'UNCANCELLATION'] as const)(
'contains %s',
(type) => expect(PREMIUM_ACTIVE_TYPES.has(type)).toBe(true)
);
it.each(['EXPIRATION', 'CANCELLATION', 'BILLING_ISSUE'] as const)(
'does not contain revocation event %s',
(type) => expect(PREMIUM_ACTIVE_TYPES.has(type)).toBe(false)
);
});
describe('PREMIUM_REVOKED_TYPES', () => {
it.each(['EXPIRATION', 'CANCELLATION', 'BILLING_ISSUE', 'SUBSCRIBER_ALIAS'] as const)(
'contains %s',
(type) => expect(PREMIUM_REVOKED_TYPES.has(type)).toBe(true)
);
it.each(['INITIAL_PURCHASE', 'RENEWAL'] as const)(
'does not contain active event %s',
(type) => expect(PREMIUM_REVOKED_TYPES.has(type)).toBe(false)
);
});
// ── applyEntitlementEvent ─────────────────────────────────────────────────────
describe('applyEntitlementEvent', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('grants premium on INITIAL_PURCHASE', async () => {
const { mockSet } = buildFirestoreMock();
await applyEntitlementEvent(event({ type: 'INITIAL_PURCHASE' }));
expect(mockSet).toHaveBeenCalledWith(
expect.objectContaining({ premium: true }),
);
});
it('revokes premium on EXPIRATION', async () => {
const { mockSet } = buildFirestoreMock();
await applyEntitlementEvent(event({ type: 'EXPIRATION' }));
expect(mockSet).toHaveBeenCalledWith(
expect.objectContaining({ premium: false, expiresAt: null }),
);
});
it('revokes premium on CANCELLATION', async () => {
const { mockSet } = buildFirestoreMock();
await applyEntitlementEvent(event({ type: 'CANCELLATION' }));
expect(mockSet).toHaveBeenCalledWith(
expect.objectContaining({ premium: false }),
);
});
it('is idempotent — skips duplicate event ids', async () => {
const { mockSet } = buildFirestoreMock({ createShouldFail: true });
await applyEntitlementEvent(event());
// The idempotency guard fires (create threw ALREADY_EXISTS), so set is never called.
expect(mockSet).not.toHaveBeenCalled();
});
it('stores expiresAt when expiration_at_ms is present', async () => {
const { mockSet } = buildFirestoreMock();
const expiresAtMs = Date.now() + 86_400_000;
await applyEntitlementEvent(event({ type: 'RENEWAL', expiration_at_ms: expiresAtMs }));
expect(mockSet).toHaveBeenCalledWith(
expect.objectContaining({ premium: true }),
);
});
it('ignores non-premium entitlement events', async () => {
const { mockSet } = buildFirestoreMock();
await applyEntitlementEvent(
event({ entitlement_id: 'some_other_entitlement', entitlement_ids: [] })
);
expect(mockSet).not.toHaveBeenCalled();
});
});

View File

@ -33,7 +33,7 @@ export interface EntitlementState {
} }
// Events that should grant or keep premium access active. // Events that should grant or keep premium access active.
const PREMIUM_ACTIVE_TYPES: Set<RevenueCatEventType> = new Set([ export const PREMIUM_ACTIVE_TYPES: Set<RevenueCatEventType> = new Set([
'INITIAL_PURCHASE', 'INITIAL_PURCHASE',
'RENEWAL', 'RENEWAL',
'PRODUCT_CHANGE', 'PRODUCT_CHANGE',
@ -42,7 +42,7 @@ const PREMIUM_ACTIVE_TYPES: Set<RevenueCatEventType> = new Set([
]) ])
// Events that remove premium access. // Events that remove premium access.
const PREMIUM_REVOKED_TYPES: Set<RevenueCatEventType> = new Set([ export const PREMIUM_REVOKED_TYPES: Set<RevenueCatEventType> = new Set([
'EXPIRATION', 'EXPIRATION',
'CANCELLATION', 'CANCELLATION',
'BILLING_ISSUE', 'BILLING_ISSUE',
@ -64,7 +64,7 @@ function now(): admin.firestore.Timestamp {
return admin.firestore.Timestamp.now() return admin.firestore.Timestamp.now()
} }
function isPremiumEntitlement(event: EntitlementEvent): boolean { export function isPremiumEntitlement(event: EntitlementEvent): boolean {
const entitlementId = event.entitlement_id const entitlementId = event.entitlement_id
const entitlementIds = event.entitlement_ids ?? [] const entitlementIds = event.entitlement_ids ?? []
if (entitlementId === PREMIUM_ENTITLEMENT_ID) return true if (entitlementId === PREMIUM_ENTITLEMENT_ID) return true