field_ios

In-the-field app for Radroots on iOS
git clone https://radroots.dev/git/field_ios.git
Log | Files | Refs | README | LICENSE

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 }