TeraTodayStoreTests.swift (14139B)
1 @testable import TeraApp 2 import XCTest 3 4 final class TeraTodayStoreTests: XCTestCase { 5 @MainActor 6 func testFrozenPaginationPreservesEqualTimestampCardsWithoutDuplicates() async throws { 7 let context = makeContext(id: "one", label: "One") 8 let first = makeCard(id: "a", type: .update, authoredAt: 1_800_000_000) 9 let second = makeCard(id: "b", type: .ask, authoredAt: 1_800_000_000) 10 let backend = TodayBackend( 11 pages: [ 12 "one:first": TeraTodayPage(asOfUnixSeconds: 1_800_000_100, items: [first], nextCursor: "same-time", calendar: TeraScopeFixtures.viewerCalendar(asOf: 1_800_000_100)), 13 "one:same-time": TeraTodayPage(asOfUnixSeconds: 1_800_000_100, items: [first, second], nextCursor: nil, calendar: TeraScopeFixtures.viewerCalendar(asOf: 1_800_000_100)), 14 ] 15 ) 16 let client = try await startedClient(backend: backend) 17 let store = TeraTodayStore( 18 runtimeClient: client, 19 contexts: [context], 20 pageSize: 1, 21 clock: .fixed(unixSeconds: 1_800_000_100) 22 ) 23 24 await store.reload(refreshProjection: false) 25 XCTAssertEqual(store.cards.map(\.id), ["a"]) 26 XCTAssertTrue(store.canLoadNextPage) 27 await store.loadNextPage() 28 29 XCTAssertEqual(store.cards.map(\.id), ["a", "b"]) 30 XCTAssertEqual(store.presentation.content, .available) 31 XCTAssertFalse(store.canLoadNextPage) 32 _ = try await client.stop() 33 } 34 35 @MainActor 36 func testContextSwitchRejectsLateCancelledContextResult() async throws { 37 let firstContext = makeContext(id: "slow", label: "Slow") 38 let secondContext = makeContext(id: "current", label: "Current") 39 let stale = makeCard(id: "stale", type: .update) 40 let current = makeCard(id: "current", type: .photoUpdate) 41 let backend = TodayBackend( 42 pages: [ 43 "slow:first": TeraTodayPage(asOfUnixSeconds: 1_800_000_100, items: [stale], nextCursor: nil, calendar: TeraScopeFixtures.viewerCalendar(asOf: 1_800_000_100)), 44 "current:first": TeraTodayPage(asOfUnixSeconds: 1_800_000_100, items: [current], nextCursor: nil, calendar: TeraScopeFixtures.viewerCalendar(asOf: 1_800_000_100)), 45 ], 46 delays: ["slow": 80_000_000] 47 ) 48 let client = try await startedClient(backend: backend) 49 let store = TeraTodayStore( 50 runtimeClient: client, 51 contexts: [firstContext, secondContext], 52 clock: .fixed(unixSeconds: 1_800_000_100) 53 ) 54 55 let staleRequest = Task { await store.reload(refreshProjection: false) } 56 try await Task.sleep(nanoseconds: 2_000_000) 57 store.selectContext(id: "current") 58 await staleRequest.value 59 try await Task.sleep(nanoseconds: 20_000_000) 60 61 XCTAssertEqual(store.selectedContextID, "current") 62 XCTAssertEqual(store.cards.map(\.id), ["current"]) 63 _ = try await client.stop() 64 } 65 66 @MainActor 67 func testFiveCardsExposeProfileThreadMediaOverlayAndAccessibleProductFields() { 68 let profile = TeraProfileSummary( 69 authorPublicKey: String(repeating: "a", count: 64), 70 name: "fieldnotes", 71 displayName: "Field Notes Farm", 72 about: nil, 73 picture: nil, 74 banner: nil, 75 nip05: "farm@example.com", 76 website: nil, 77 lightningAddress: nil 78 ) 79 let failedMedia = TeraMediaReference( 80 referenceFingerprint: String(repeating: "d", count: 64), 81 url: "https://blossom.example/\(String(repeating: "b", count: 64))", 82 sha256: String(repeating: "b", count: 64), 83 mediaType: "image/jpeg", 84 width: 1200, 85 height: 800, 86 byteSize: 400, 87 alt: "Carrots in a basket", 88 verification: .failed 89 ) 90 let thread = TeraThreadEntry( 91 id: "reply", 92 authorPublicKey: String(repeating: "c", count: 64), 93 content: "Are these available Saturday?", 94 authoredAtUnixSeconds: 1_800_000_001, 95 type: .reply, 96 root: "root", 97 parentEventID: "root", 98 authorProfile: nil 99 ) 100 let cards = TeraTodayCardType.allCases.map { type in 101 makeCard( 102 id: type.rawValue, 103 type: type, 104 profile: profile, 105 media: type == .photoUpdate ? [failedMedia] : [], 106 thread: type == .ask ? [thread] : [], 107 localState: type == .foodAvailability ? "partially_delivered" : nil 108 ) 109 } 110 111 XCTAssertEqual(cards.map(\.type), TeraTodayCardType.allCases) 112 XCTAssertEqual(cards[1].media.first?.verifiedArtifactID, nil) 113 XCTAssertEqual(cards[2].thread.first?.type, .reply) 114 XCTAssertEqual(cards[4].priceSummary, "3 CAD/lb") 115 XCTAssertEqual(cards[4].quantity, "12") 116 XCTAssertTrue(cards[4].accessibilitySummary.contains("Food availability")) 117 XCTAssertTrue(cards[4].accessibilitySummary.contains("Field Notes Farm")) 118 XCTAssertTrue(cards[4].accessibilitySummary.contains("partially_delivered")) 119 } 120 121 @MainActor 122 func testStorageRefreshFailureKeepsCachedFeedWithoutClaimingOffline() async throws { 123 try await assertCachedRefreshFailure( 124 code: "today_storage_failed", 125 expected: .failed(message: TeraUserMessages.text(.secureStateUnavailable)) 126 ) 127 } 128 129 @MainActor 130 func testOfflineRefreshStillLoadsTheDurableCachedFeed() async throws { 131 try await assertCachedRefreshFailure( 132 code: "today_relay_offline", 133 expected: .offline(message: TeraUserMessages.text(.networkUnavailable)) 134 ) 135 } 136 137 @MainActor 138 func testOfflineRefreshStillShowsTheGenuineEmptyLocalPage() async throws { 139 try await assertCachedRefreshFailure( 140 code: "today_relay_offline", 141 expected: .offline(message: TeraUserMessages.text(.networkUnavailable)), 142 hasCachedCards: false 143 ) 144 } 145 146 @MainActor 147 private func assertCachedRefreshFailure( 148 code: String, expected: TeraTodayFailure, hasCachedCards: Bool = true 149 ) async throws { 150 let context = makeContext(id: "offline", label: "Offline") 151 let cached = makeCard(id: "cached", type: .update) 152 let failure = TeraRuntimeFailure( 153 schemaVersion: 1, 154 code: code, 155 category: "relay", 156 retryable: true, 157 recoveryActions: ["retry"], 158 operationID: "test.today.refresh", 159 capabilityID: "nostr_source", 160 safeMessage: "Saved posts are available offline." 161 ) 162 let backend = TodayBackend( 163 pages: [ 164 "offline:first": TeraTodayPage(asOfUnixSeconds: 1_800_000_100, items: hasCachedCards ? [cached] : [], nextCursor: hasCachedCards ? "cached-next" : nil, calendar: TeraScopeFixtures.viewerCalendar(asOf: 1_800_000_100)), 165 "offline:cached-next": TeraTodayPage(asOfUnixSeconds: 1_800_000_100, items: [makeCard(id: "cached-next", type: .ask)], nextCursor: nil, calendar: TeraScopeFixtures.viewerCalendar(asOf: 1_800_000_100)), 166 ], 167 refreshFailure: failure 168 ) 169 let client = try await startedClient(backend: backend) 170 let store = TeraTodayStore( 171 runtimeClient: client, 172 contexts: [context], 173 clock: .fixed(unixSeconds: 1_800_000_100) 174 ) 175 176 await store.reload() 177 178 XCTAssertEqual(store.cards.map(\.id), hasCachedCards ? ["cached"] : []) 179 XCTAssertEqual(store.presentation.content, hasCachedCards ? .available : .empty) 180 XCTAssertEqual(store.presentation.refresh, .failed(expected)) 181 await store.loadNextPage() 182 XCTAssertEqual(store.cards.map(\.id), hasCachedCards ? ["cached", "cached-next"] : []) 183 XCTAssertEqual(store.presentation.content, hasCachedCards ? .available : .empty) 184 XCTAssertEqual(store.presentation.refresh, .failed(expected)) 185 _ = try await client.stop() 186 } 187 188 @MainActor 189 private func startedClient(backend: TodayBackend) async throws -> TeraRuntimeClient { 190 let client = TeraRuntimeClient { _ in 191 await TeraRuntimeBackendStart(backend: backend, snapshot: backend.snapshot()) 192 } 193 _ = try await client.start(configuration: makeConfiguration()) 194 return client 195 } 196 197 private func makeConfiguration() -> TeraRuntimeLaunchConfiguration { 198 TeraRuntimeLaunchConfiguration( 199 applicationSupportDirectory: "/tmp/radroots-today-tests", 200 publicKeyHex: String(repeating: "ab", count: 32), 201 sourceGenerationHex: String(repeating: "cd", count: 32), 202 sourceGenerationCreatedAtUnixMilliseconds: 1, 203 protectedData: .available, 204 networkProfile: .simulator, 205 writableRelays: ["ws://127.0.0.1:7447"], 206 blossom: TeraBlossomEndpointConfiguration( 207 hostKind: .simulator, 208 endpointAuthority: .loopbackDevelopment, 209 primaryOrigin: "http://127.0.0.1:3000", 210 fallbackOrigins: [] 211 ), 212 app: TeraRuntimeAppMetadata( 213 bundleIdentifier: "org.radroots.today-tests", 214 version: "0.1.0-alpha", 215 buildNumber: "1", 216 buildSHA: nil 217 ), 218 signerGeneration: "today-tests", 219 signer: TodayTestSigner(), 220 adoptBootstrapSettings: false 221 ) 222 } 223 } 224 225 private func makeContext(id: String, label: String) -> TeraLocalNetwork { 226 TeraLocalNetwork( 227 schemaVersion: 1, 228 id: id, 229 label: label, 230 relayURLs: ["ws://127.0.0.1:7447"], 231 locality: nil, 232 followedAuthors: [], 233 generation: 1 234 ) 235 } 236 237 private func makeCard( 238 id: String, 239 type: TeraTodayCardType, 240 authoredAt: UInt64 = 1_800_000_000, 241 profile: TeraProfileSummary? = nil, 242 media: [TeraMediaReference] = [], 243 thread: [TeraThreadEntry] = [], 244 localState: String? = nil 245 ) -> TeraTodayCard { 246 TeraTodayCard( 247 id: id, 248 type: type, 249 sourceEventID: "event-\(id)", 250 sourceAddress: type == .event || type == .foodAvailability ? "address-\(id)" : nil, 251 authorPublicKey: String(repeating: "a", count: 64), 252 contractID: "contract-\(type.rawValue)", 253 title: type == .event ? "Saturday market" : type == .foodAvailability ? "Carrots" : nil, 254 content: type == .ask ? "Who has seedlings?" : "Fresh from the field", 255 authoredAtUnixSeconds: authoredAt, 256 effectiveAtUnixSeconds: authoredAt, 257 calendarTiming: type == .event ? .timeBased( 258 startUnixSeconds: authoredAt + 3600, endExclusiveUnixSeconds: nil, 259 startTimezoneID: nil, endTimezoneID: nil 260 ) : nil, 261 location: type == .event || type == .foodAvailability ? "Town square" : nil, 262 priceAmount: type == .foodAvailability ? "3" : nil, 263 priceCurrency: type == .foodAvailability ? "CAD" : nil, 264 priceUnit: type == .foodAvailability ? "lb" : nil, 265 quantity: type == .foodAvailability ? "12" : nil, 266 foodSummary: type == .foodAvailability ? "Fresh carrots" : nil, 267 foodPublishedAtUnixSeconds: type == .foodAvailability ? authoredAt : nil, 268 foodStatus: type == .foodAvailability ? "active" : nil, 269 contextRank: 1, 270 inclusionReason: "locality_missing_fallback", 271 media: media, 272 lifecycle: .active, 273 rankDigest: "rank-\(id)", 274 authorProfile: profile, 275 thread: thread, 276 localOperationID: localState == nil ? nil : "operation-\(id)", 277 localOperationState: localState 278 ) 279 } 280 281 private struct TodayTestSigner: TeraRuntimeSigner { 282 func availability() async -> TeraRuntimeSignerAvailability { 283 .ready 284 } 285 286 func sign(_: TeraRuntimeSigningRequest) async -> TeraRuntimeSigningOutcome { 287 .failed 288 } 289 } 290 291 private actor TodayBackend: TeraRuntimeBackend { 292 private let pages: [String: TeraTodayPage] 293 private let delays: [String: UInt64] 294 private let refreshFailure: TeraRuntimeFailure? 295 private var closed = false 296 297 init( 298 pages: [String: TeraTodayPage], 299 delays: [String: UInt64] = [:], 300 refreshFailure: TeraRuntimeFailure? = nil 301 ) { 302 self.pages = pages 303 self.delays = delays 304 self.refreshFailure = refreshFailure 305 } 306 307 func snapshot() -> TeraRuntimeSnapshot { 308 TeraRuntimeSnapshot( 309 identity: TeraRuntimeIdentity( 310 publicKeyHex: String(repeating: "ab", count: 32), 311 hostSignerConfigured: true 312 ), 313 relay: nil, 314 blossomConfiguration: nil, 315 blossomEvidence: nil, 316 crateName: "tera_ffi", 317 crateVersion: "0.1.0-alpha", 318 isClosed: closed 319 ) 320 } 321 322 func todayPage(request: TeraTodayPageRequest) async throws -> TeraTodayPage { 323 if let delay = delays[request.context.id] { 324 try await Task.sleep(nanoseconds: delay) 325 } 326 let key = "\(request.context.id):\(request.cursor ?? "first")" 327 guard let page = pages[key] else { 328 throw TeraRuntimeFailure.local( 329 operation: "test.today.page", 330 code: "test.page_missing", 331 safeMessage: "The requested test page is missing." 332 ) 333 } 334 return page 335 } 336 337 func refreshToday( 338 context _: TeraLocalNetwork, 339 nowUnixSeconds _: UInt64, 340 update: TeraTodayProjectionUpdate, 341 backfillCursor _: String? 342 ) throws -> TeraTodaySyncReceipt { 343 if let refreshFailure { 344 throw refreshFailure 345 } 346 return TeraTodaySyncFixtures.receipt(update: update) 347 } 348 349 func subscribe( 350 bufferCapacity _: Int, 351 receive _: @escaping @Sendable (TeraRuntimeChange) async -> Void 352 ) -> any TeraRuntimeSubscriptionToken { 353 TodaySubscriptionToken() 354 } 355 356 func shutdown() -> TeraRuntimeShutdownReceipt { 357 let alreadyClosed = closed 358 closed = true 359 return TeraRuntimeShutdownReceipt(state: "closed", alreadyClosed: alreadyClosed) 360 } 361 } 362 363 private actor TodaySubscriptionToken: TeraRuntimeSubscriptionToken { 364 func cancel() {} 365 }