diff --git a/app/src/main/java/app/closer/domain/MemoryCapsuleGenerator.kt b/app/src/main/java/app/closer/domain/MemoryCapsuleGenerator.kt index 7d1193c1..6357b764 100644 --- a/app/src/main/java/app/closer/domain/MemoryCapsuleGenerator.kt +++ b/app/src/main/java/app/closer/domain/MemoryCapsuleGenerator.kt @@ -23,6 +23,10 @@ import java.time.temporal.ChronoUnit * - Reveal-derived titles use only category metadata and the fact that a reveal happened. * - Manual notes are accepted as already-encrypted ciphertext; this object does not encrypt. * - Tags are built from category, source type, and date context only. + * + * Determinism: every factory takes an injectable `createdAtMillis` (defaulting to the wall clock) + * so that, given identical inputs, the produced capsule is identical. Tests pin it; production omits + * it to stamp the real creation time. */ object MemoryCapsuleGenerator { @@ -67,7 +71,8 @@ object MemoryCapsuleGenerator { event: MemoryDateIdeaEvent, noteCiphertext: String? = null, photoStoragePath: String? = null, - now: LocalDate = LocalDate.now() + now: LocalDate = LocalDate.now(), + createdAtMillis: Long = System.currentTimeMillis() ): MemoryCapsule { val date = event.scheduledDate ?: now val displayCategory = event.category.takeIf { it.isNotBlank() } ?: DATE_CATEGORY @@ -92,7 +97,7 @@ object MemoryCapsuleGenerator { ), source = MemoryCapsuleSource.DATE_IDEA, sourceRefId = event.dateIdeaId, - createdAt = System.currentTimeMillis() + createdAt = createdAtMillis ) } @@ -106,7 +111,8 @@ object MemoryCapsuleGenerator { authorId: String, event: MemoryRevealEvent, noteCiphertext: String? = null, - photoStoragePath: String? = null + photoStoragePath: String? = null, + createdAtMillis: Long = System.currentTimeMillis() ): MemoryCapsule { val displayCategory = event.categoryId?.takeIf { it.isNotBlank() } ?: REVEAL_CATEGORY val title = "A moment we revealed together${categoryFragment(displayCategory)}" @@ -127,7 +133,7 @@ object MemoryCapsuleGenerator { ), source = MemoryCapsuleSource.REVEAL, sourceRefId = event.questionId, - createdAt = System.currentTimeMillis() + createdAt = createdAtMillis ) } @@ -139,7 +145,8 @@ object MemoryCapsuleGenerator { authorId: String, event: MemoryRecapEvent, noteCiphertext: String? = null, - photoStoragePath: String? = null + photoStoragePath: String? = null, + createdAtMillis: Long = System.currentTimeMillis() ): MemoryCapsule { val displayCategory = event.favoriteCategory?.takeIf { it.isNotBlank() } ?: RECAP_CATEGORY val weekNumber = weekNumberLabel(event.weekStart, event.weekEnd) @@ -162,7 +169,7 @@ object MemoryCapsuleGenerator { ), source = MemoryCapsuleSource.WEEKLY_RECAP, sourceRefId = event.recapId, - createdAt = System.currentTimeMillis() + createdAt = createdAtMillis ) } @@ -174,7 +181,8 @@ object MemoryCapsuleGenerator { authorId: String, event: MemoryChallengeEvent, noteCiphertext: String? = null, - photoStoragePath: String? = null + photoStoragePath: String? = null, + createdAtMillis: Long = System.currentTimeMillis() ): MemoryCapsule { val displayCategory = event.challengeCategory?.takeIf { it.isNotBlank() } ?: CHALLENGE_CATEGORY val title = "Challenge completed${categoryFragment(displayCategory)}" @@ -195,7 +203,7 @@ object MemoryCapsuleGenerator { ), source = MemoryCapsuleSource.CHALLENGE, sourceRefId = event.challengeId, - createdAt = System.currentTimeMillis() + createdAt = createdAtMillis ) } @@ -209,7 +217,8 @@ object MemoryCapsuleGenerator { coupleId: String, authorId: String, input: MemoryManualInput, - now: LocalDate = LocalDate.now() + now: LocalDate = LocalDate.now(), + createdAtMillis: Long = System.currentTimeMillis() ): MemoryCapsule { val displayCategory = input.category.takeIf { it.isNotBlank() } ?: DEFAULT_CATEGORY val title = input.title.takeIf { it.isNotBlank() } @@ -231,7 +240,7 @@ object MemoryCapsuleGenerator { now = now ), source = MemoryCapsuleSource.MANUAL, - createdAt = System.currentTimeMillis() + createdAt = createdAtMillis ) } diff --git a/app/src/test/java/app/closer/domain/MemoryCapsuleGeneratorTest.kt b/app/src/test/java/app/closer/domain/MemoryCapsuleGeneratorTest.kt index f9bf4a6c..a642cbeb 100644 --- a/app/src/test/java/app/closer/domain/MemoryCapsuleGeneratorTest.kt +++ b/app/src/test/java/app/closer/domain/MemoryCapsuleGeneratorTest.kt @@ -425,8 +425,11 @@ class MemoryCapsuleGeneratorTest { scheduledDate = today ) - val first = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today) - val second = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today) + // Pin the clock: the generator documents itself as pure/deterministic, so identical inputs + // (including createdAtMillis) must yield identical capsules. Without an injected clock the + // createdAt timestamp made this flaky across millisecond boundaries. + val first = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today, createdAtMillis = 1_700_000_000_000L) + val second = MemoryCapsuleGenerator.fromDateIdea(coupleId, authorId, event, now = today, createdAtMillis = 1_700_000_000_000L) assertEquals(first, second) }