| 1 | import XCTest |
| 2 | @testable import LChatCore |
| 3 | |
| 4 | // 🤖 Unfurling your OWN just-posted message: the card used to appear only |
| 5 | // after a reload. The row for a pending send is rebuilt the moment its echo |
| 6 | // swaps the pending id for the real server id, and the rebuild cancels the old |
| 7 | // row's `.task`. With the fetch parented to that task, the cancellation landed |
| 8 | // as a FAILED unfurl, negative-cached the URL for a minute, and the |
| 9 | // replacement row was told there was nothing to show. |
| 10 | // |
| 11 | // So the two rules pinned here: the fetch survives its caller's cancellation, |
| 12 | // and a caller arriving mid-fetch awaits the same one instead of being told no. |
| 13 | #if DEBUG |
| 14 | final class UnfurlSharingTests: XCTestCase { |
| 15 | @MainActor |
| 16 | private func makeModel() -> ChatFeedModel { |
| 17 | let inst = SavedInstance(origin: "https://example.invalid", deviceToken: "tok", |
| 18 | screenName: "Paul", userPub: "", userPriv: "") |
| 19 | let thread = ChatThread(id: "t1", kind: "dm", title: "Eleven", preview: "", |
| 20 | lastAt: 0, unread: false, pinned: false, hidden: false, |
| 21 | members: [], threadKeyB64: "") |
| 22 | return ChatFeedModel(instance: inst, thread: thread, myUserId: "me") |
| 23 | } |
| 24 | |
| 25 | private static let link = URL(string: "https://evilmartians.com")! |
| 26 | private static let card = LinkPreview(url: link, title: "Evil Martians", |
| 27 | description: "", imageURL: nil) |
| 28 | |
| 29 | /// The row that replaces a cancelled one still gets its card. |
| 30 | @MainActor |
| 31 | func testFetchOutlivesACancelledCaller() async { |
| 32 | let model = makeModel() |
| 33 | let started = expectation(description: "fetch started") |
| 34 | let calls = Counter() |
| 35 | model._testUnfurlFetch = { _ in |
| 36 | calls.bump() |
| 37 | started.fulfill() |
| 38 | try? await Task.sleep(nanoseconds: 120_000_000) |
| 39 | return Self.card |
| 40 | } |
| 41 | |
| 42 | // The pending row's task, cancelled the way a rebuilt row cancels it. |
| 43 | let row = Task { @MainActor in await model.unfurl(Self.link) } |
| 44 | await fulfillment(of: [started], timeout: 2) |
| 45 | row.cancel() |
| 46 | |
| 47 | // The replacement row asks again while the first fetch is still out. |
| 48 | let replacement = await model.unfurl(Self.link) |
| 49 | XCTAssertEqual(replacement, Self.card, |
| 50 | "the rebuilt row must get the card the cancelled row started") |
| 51 | XCTAssertEqual(calls.value, 1, "one fetch, shared — not one per row") |
| 52 | XCTAssertEqual(model.cachedUnfurl(Self.link), .some(Self.card), |
| 53 | "and the cancellation must not have poisoned the cache") |
| 54 | } |
| 55 | |
| 56 | /// Two rows asking at once both get the card — the second used to get nil |
| 57 | /// (the old in-flight guard) and cache that as "nothing to show". |
| 58 | @MainActor |
| 59 | func testConcurrentCallersShareOneFetch() async { |
| 60 | let model = makeModel() |
| 61 | let calls = Counter() |
| 62 | model._testUnfurlFetch = { _ in |
| 63 | calls.bump() |
| 64 | try? await Task.sleep(nanoseconds: 50_000_000) |
| 65 | return Self.card |
| 66 | } |
| 67 | async let a = model.unfurl(Self.link) |
| 68 | async let b = model.unfurl(Self.link) |
| 69 | let (first, second) = await (a, b) |
| 70 | XCTAssertEqual(first, Self.card) |
| 71 | XCTAssertEqual(second, Self.card) |
| 72 | XCTAssertEqual(calls.value, 1) |
| 73 | } |
| 74 | |
| 75 | /// A page with nothing to show still fails closed, and briefly — the TTL |
| 76 | /// that keeps a re-render from stampeding the server. |
| 77 | @MainActor |
| 78 | func testEmptyResultIsNegativeCachedThenRetried() async { |
| 79 | let model = makeModel() |
| 80 | model._testUnfurlFetch = { _ in nil } |
| 81 | let empty = await model.unfurl(Self.link) |
| 82 | XCTAssertNil(empty) |
| 83 | XCTAssertEqual(model.cachedUnfurl(Self.link), .some(nil), |
| 84 | "known-empty, so height measurement doesn't leave a gap") |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | /// Call tally — MainActor like the model whose fetch it counts. |
| 89 | @MainActor private final class Counter { |
| 90 | var value = 0 |
| 91 | func bump() { value += 1 } |
| 92 | } |
| 93 | #endif |
| 94 | |