1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
@testable import Grain
import XCTest
/// File scope, not a static member: `MockURLProtocol.handler` runs outside the
/// main actor, so anything it reads has to live outside a @MainActor class.
private func feedBody(ids: [Int], cursor: String?) -> String {
let items = ids.map { id in
"""
{
"uri": "at://did:plc:a/social.grain.gallery/\(id)",
"cid": "bafyg\(id)",
"title": "Gallery \(id)",
"creator": {"cid": "c1", "did": "did:plc:a", "handle": "alice.test"},
"indexedAt": "2025-01-02T00:00:00Z"
}
"""
}.joined(separator: ",")
let cursorJSON = cursor.map { "\"\($0)\"" } ?? "null"
return "{\"items\": [\(items)], \"cursor\": \(cursorJSON)}"
}
/// The feed is the first screen and its view model has to survive a cold cache,
/// a failed fetch, and a tab switch mid-request. The cursor and the disk cache
/// are where it goes wrong quietly: a stale cursor re-appends a page, and a
/// cache key collision shows one account another's galleries.
@MainActor
final class FeedViewModelTests: GrainTestCase {
private var client: XRPCClient!
override func setUp() async throws {
try await super.setUp()
client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession())
}
override func tearDown() async throws {
MockURLProtocol.handler = nil
try await super.tearDown()
}
private func makeViewModel(feedName: String = "recent", cacheKey: String? = nil) -> FeedViewModel {
FeedViewModel(client: client, feedName: feedName, cacheKey: cacheKey)
}
// MARK: - loadInitial
func testLoadingFillsTheFeed() async {
MockURLProtocol.respondWithJSON(feedBody(ids: [1, 2], cursor: nil))
let vm = makeViewModel()
await vm.loadInitial()
XCTAssertEqual(vm.galleries.map(\.title), ["Gallery 1", "Gallery 2"])
XCTAssertFalse(vm.isLoading)
XCTAssertNil(vm.error)
XCTAssertTrue(vm.hasFetchedInitial)
}
/// The flag drives "we've heard from the server at least once", so a
/// failure has to set it too or the screen retries forever.
func testAFailedLoadStillCountsAsHavingFetched() async {
MockURLProtocol.respondWithError(statusCode: 500)
let vm = makeViewModel()
await vm.loadInitial()
XCTAssertTrue(vm.hasFetchedInitial)
XCTAssertNotNil(vm.error)
XCTAssertFalse(vm.isLoading)
XCTAssertTrue(vm.galleries.isEmpty)
}
func testASuccessfulReloadClearsAPreviousError() async {
MockURLProtocol.respondWithError(statusCode: 500)
let vm = makeViewModel()
await vm.loadInitial()
XCTAssertNotNil(vm.error)
MockURLProtocol.respondWithJSON(feedBody(ids: [1], cursor: nil))
await vm.loadInitial()
XCTAssertNil(vm.error)
XCTAssertEqual(vm.galleries.count, 1)
}
func testReloadingReplacesRatherThanAppends() async {
MockURLProtocol.respondWithJSON(feedBody(ids: [1, 2], cursor: nil))
let vm = makeViewModel()
await vm.loadInitial()
MockURLProtocol.respondWithJSON(feedBody(ids: [3], cursor: nil))
await vm.loadInitial()
XCTAssertEqual(vm.galleries.map(\.title), ["Gallery 3"])
}
/// Pull-to-refresh after paging has to forget where it had got to, or the
/// next page continues from a cursor into the old result set.
func testAReloadForgetsThePagingCursor() async {
MockURLProtocol.respondWithJSON(feedBody(ids: [1], cursor: "page2"))
let vm = makeViewModel()
await vm.loadInitial()
MockURLProtocol.respondWithJSON(feedBody(ids: [9], cursor: nil))
await vm.loadInitial()
var requestMade = false
MockURLProtocol.handler = { request in
requestMade = true
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(feedBody(ids: [99], cursor: nil).utf8), response)
}
await vm.loadMore()
XCTAssertFalse(requestMade, "The exhausted second response should have ended paging")
XCTAssertEqual(vm.galleries.map(\.title), ["Gallery 9"])
}
// MARK: - loadMore
func testPagingAppendsTheNextPage() async {
MockURLProtocol.respondWithJSON(feedBody(ids: [1], cursor: "page2"))
let vm = makeViewModel()
await vm.loadInitial()
MockURLProtocol.respondWithJSON(feedBody(ids: [2, 3], cursor: nil))
await vm.loadMore()
XCTAssertEqual(vm.galleries.map(\.title), ["Gallery 1", "Gallery 2", "Gallery 3"])
}
/// Scrolling to the bottom fires this repeatedly.
func testPagingStopsOnceTheServerRunsOut() async {
MockURLProtocol.respondWithJSON(feedBody(ids: [1], cursor: "page2"))
let vm = makeViewModel()
await vm.loadInitial()
MockURLProtocol.respondWithJSON(feedBody(ids: [2], cursor: nil))
await vm.loadMore()
var extraRequests = 0
MockURLProtocol.handler = { request in
extraRequests += 1
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(feedBody(ids: [99], cursor: nil).utf8), response)
}
await vm.loadMore()
XCTAssertEqual(extraRequests, 0)
XCTAssertEqual(vm.galleries.count, 2)
}
func testThereIsNothingToPageBeforeTheFirstLoad() async {
var requestMade = false
MockURLProtocol.handler = { request in
requestMade = true
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(feedBody(ids: [1], cursor: nil).utf8), response)
}
await makeViewModel().loadMore()
XCTAssertFalse(requestMade)
}
func testAFailedPageKeepsWhatIsAlreadyOnScreen() async {
MockURLProtocol.respondWithJSON(feedBody(ids: [1], cursor: "page2"))
let vm = makeViewModel()
await vm.loadInitial()
MockURLProtocol.respondWithError(statusCode: 500)
await vm.loadMore()
XCTAssertEqual(vm.galleries.map(\.title), ["Gallery 1"])
XCTAssertNotNil(vm.error)
XCTAssertFalse(vm.isLoading)
}
// MARK: - Disk cache
/// The cache exists so the feed renders before the first network response —
/// a new view model on the same key has to see it without fetching.
func testACachedFeedIsAvailableBeforeAnyRequest() async {
// The cache files entries under the active account, so pointing that at
// a synthetic DID keeps this out of the installed app's real cache.
let did = "did:plc:feedvm-\(UUID().uuidString)"
let savedAccount = AccountScopedStorage.activeAccountID
AccountScopedStorage.activeAccountID = did
defer {
FeedCache.shared.purge(did: did)
AccountScopedStorage.activeAccountID = savedAccount
}
let key = "feedvm-\(UUID().uuidString)"
MockURLProtocol.respondWithJSON(feedBody(ids: [1, 2], cursor: nil))
await makeViewModel(cacheKey: key).loadInitial()
// The write is handed to a detached task, so give it a moment to land.
try? await Task.sleep(for: .milliseconds(300))
let restored = makeViewModel(cacheKey: key)
XCTAssertEqual(restored.galleries.map(\.title), ["Gallery 1", "Gallery 2"])
XCTAssertFalse(restored.hasFetchedInitial, "Cached content isn't a fetch")
}
func testAViewModelWithNoCacheKeyStartsEmpty() {
XCTAssertTrue(makeViewModel().galleries.isEmpty)
}
// MARK: - Pinned feed convenience init
/// A following or for-you feed is scoped to the viewer; the others aren't,
/// and sending an actor on those would silently filter the feed.
func testOnlyThePersonalFeedsCarryTheViewersDID() async {
for (feed, expectsActor) in [
(PinnedFeed.defaults[0], false),
(PinnedFeed.defaults[1], true),
(PinnedFeed.defaults[2], true),
] {
let seen = ParamRecorder()
MockURLProtocol.handler = { request in
seen.record(request.url)
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(feedBody(ids: [], cursor: nil).utf8), response)
}
let vm = FeedViewModel(client: client, pinnedFeed: feed, userDID: "did:plc:me")
await vm.loadInitial()
XCTAssertEqual(
seen.value(for: "actor"), expectsActor ? "did:plc:me" : nil,
"\(feed.id) sent the wrong actor"
)
XCTAssertEqual(seen.value(for: "feed"), feed.feedName)
}
}
func testACameraFeedSendsTheCameraName() async {
let seen = ParamRecorder()
MockURLProtocol.handler = { request in
seen.record(request.url)
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(feedBody(ids: [], cursor: nil).utf8), response)
}
let feed = PinnedFeed(id: "camera:Leica M6", label: "Leica M6", type: "camera", path: "/c/leica")
await FeedViewModel(client: client, pinnedFeed: feed).loadInitial()
XCTAssertEqual(seen.value(for: "feed"), "camera")
XCTAssertEqual(seen.value(for: "camera"), "Leica M6")
}
func testAHashtagFeedSendsTheTag() async {
let seen = ParamRecorder()
MockURLProtocol.handler = { request in
seen.record(request.url)
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
return (Data(feedBody(ids: [], cursor: nil).utf8), response)
}
let feed = PinnedFeed(id: "hashtag:film", label: "#film", type: "hashtag", path: "/t/film")
await FeedViewModel(client: client, pinnedFeed: feed).loadInitial()
XCTAssertEqual(seen.value(for: "feed"), "hashtag")
XCTAssertEqual(seen.value(for: "tag"), "film")
}
}
/// Captures the URL a request went to, from off the main actor.
private final class ParamRecorder: @unchecked Sendable {
private let lock = NSLock()
private var url: URL?
func record(_ url: URL?) {
lock.withLock { self.url = url }
}
func value(for name: String) -> String? {
let captured = lock.withLock { url }
guard let captured,
let items = URLComponents(url: captured, resolvingAgainstBaseURL: false)?.queryItems
else { return nil }
return items.first { $0.name == name }?.value
}
}