import XCTest @testable import LChatCore // 🤖 Unfurling your OWN just-posted message: the card used to appear only // after a reload. The row for a pending send is rebuilt the moment its echo // swaps the pending id for the real server id, and the rebuild cancels the old // row's `.task`. With the fetch parented to that task, the cancellation landed // as a FAILED unfurl, negative-cached the URL for a minute, and the // replacement row was told there was nothing to show. // // So the two rules pinned here: the fetch survives its caller's cancellation, // and a caller arriving mid-fetch awaits the same one instead of being told no. #if DEBUG final class UnfurlSharingTests: XCTestCase { @MainActor private func makeModel() -> ChatFeedModel { let inst = SavedInstance(origin: "https://example.invalid", deviceToken: "tok", screenName: "Paul", userPub: "", userPriv: "") let thread = ChatThread(id: "t1", kind: "dm", title: "Eleven", preview: "", lastAt: 0, unread: false, pinned: false, hidden: false, members: [], threadKeyB64: "") return ChatFeedModel(instance: inst, thread: thread, myUserId: "me") } private static let link = URL(string: "https://evilmartians.com")! private static let card = LinkPreview(url: link, title: "Evil Martians", description: "", imageURL: nil) /// The row that replaces a cancelled one still gets its card. @MainActor func testFetchOutlivesACancelledCaller() async { let model = makeModel() let started = expectation(description: "fetch started") let calls = Counter() model._testUnfurlFetch = { _ in calls.bump() started.fulfill() try? await Task.sleep(nanoseconds: 120_000_000) return Self.card } // The pending row's task, cancelled the way a rebuilt row cancels it. let row = Task { @MainActor in await model.unfurl(Self.link) } await fulfillment(of: [started], timeout: 2) row.cancel() // The replacement row asks again while the first fetch is still out. let replacement = await model.unfurl(Self.link) XCTAssertEqual(replacement, Self.card, "the rebuilt row must get the card the cancelled row started") XCTAssertEqual(calls.value, 1, "one fetch, shared — not one per row") XCTAssertEqual(model.cachedUnfurl(Self.link), .some(Self.card), "and the cancellation must not have poisoned the cache") } /// Two rows asking at once both get the card — the second used to get nil /// (the old in-flight guard) and cache that as "nothing to show". @MainActor func testConcurrentCallersShareOneFetch() async { let model = makeModel() let calls = Counter() model._testUnfurlFetch = { _ in calls.bump() try? await Task.sleep(nanoseconds: 50_000_000) return Self.card } async let a = model.unfurl(Self.link) async let b = model.unfurl(Self.link) let (first, second) = await (a, b) XCTAssertEqual(first, Self.card) XCTAssertEqual(second, Self.card) XCTAssertEqual(calls.value, 1) } /// A page with nothing to show still fails closed, and briefly — the TTL /// that keeps a re-render from stampeding the server. @MainActor func testEmptyResultIsNegativeCachedThenRetried() async { let model = makeModel() model._testUnfurlFetch = { _ in nil } let empty = await model.unfurl(Self.link) XCTAssertNil(empty) XCTAssertEqual(model.cachedUnfurl(Self.link), .some(nil), "known-empty, so height measurement doesn't leave a gap") } } /// Call tally — MainActor like the model whose fetch it counts. @MainActor private final class Counter { var value = 0 func bump() { value += 1 } } #endif