fix: test infrastructure and entitlement logic updates
This commit is contained in:
parent
827cfd2e2d
commit
84390a48fc
|
|
@ -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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
// 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
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue