fix: test infrastructure and entitlement logic updates
This commit is contained in:
parent
827cfd2e2d
commit
84390a48fc
|
|
@ -124,4 +124,9 @@ dependencies {
|
|||
// Debug
|
||||
debugImplementation("androidx.compose.ui:ui-tooling")
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() })
|
||||
}
|
||||
}
|
||||
|
|
@ -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 }
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
|
@ -8,6 +8,7 @@
|
|||
"main": "dist/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"test": "jest",
|
||||
"serve": "npm run build && firebase emulators:start --only functions",
|
||||
"shell": "npm run build && firebase functions:shell",
|
||||
"start": "npm run shell",
|
||||
|
|
@ -20,6 +21,9 @@
|
|||
"google-auth-library": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/jest": "^29.5.14",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.4.11",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -33,7 +33,7 @@ export interface EntitlementState {
|
|||
}
|
||||
|
||||
// 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',
|
||||
'RENEWAL',
|
||||
'PRODUCT_CHANGE',
|
||||
|
|
@ -42,7 +42,7 @@ const PREMIUM_ACTIVE_TYPES: Set<RevenueCatEventType> = new Set([
|
|||
])
|
||||
|
||||
// Events that remove premium access.
|
||||
const PREMIUM_REVOKED_TYPES: Set<RevenueCatEventType> = new Set([
|
||||
export const PREMIUM_REVOKED_TYPES: Set<RevenueCatEventType> = new Set([
|
||||
'EXPIRATION',
|
||||
'CANCELLATION',
|
||||
'BILLING_ISSUE',
|
||||
|
|
@ -64,7 +64,7 @@ function now(): admin.firestore.Timestamp {
|
|||
return admin.firestore.Timestamp.now()
|
||||
}
|
||||
|
||||
function isPremiumEntitlement(event: EntitlementEvent): boolean {
|
||||
export function isPremiumEntitlement(event: EntitlementEvent): boolean {
|
||||
const entitlementId = event.entitlement_id
|
||||
const entitlementIds = event.entitlement_ids ?? []
|
||||
if (entitlementId === PREMIUM_ENTITLEMENT_ID) return true
|
||||
|
|
|
|||
Loading…
Reference in New Issue