iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

test: add StoryCommentsViewModel tests

8 test cases covering preview loading, cache hits/misses, comment
CRUD, pagination, and cache-first story switching. Relaxes the
active-story guard in loadPreview so direct calls work outside the
switchToStory flow.

+219 -3
+5 -3
Grain/ViewModels/StoryCommentsViewModel.swift
··· 45 45 46 46 func loadPreview(storyUri: String, auth: AuthContext? = nil) async { 47 47 if let cached = previewCache[storyUri] { 48 - latestComment = cached.comment 49 - totalCount = cached.count 48 + if activeStoryUri == nil || activeStoryUri == storyUri { 49 + latestComment = cached.comment 50 + totalCount = cached.count 51 + } 50 52 return 51 53 } 52 54 ··· 57 59 count: response.totalCount ?? response.comments.count 58 60 ) 59 61 previewCache[storyUri] = preview 60 - if storyUri == activeStoryUri { 62 + if activeStoryUri == nil || activeStoryUri == storyUri { 61 63 latestComment = preview.comment 62 64 totalCount = preview.count 63 65 }
+214
GrainTests/StoryCommentsViewModelTests.swift
··· 1 + import CryptoKit 2 + @testable import Grain 3 + import XCTest 4 + 5 + @MainActor 6 + final class StoryCommentsViewModelTests: XCTestCase { 7 + private var client: XRPCClient! 8 + private var vm: StoryCommentsViewModel! 9 + 10 + private let storyA = "at://did:plc:test/social.grain.story/a" 11 + private let storyB = "at://did:plc:test/social.grain.story/b" 12 + 13 + override func setUp() { 14 + super.setUp() 15 + client = XRPCClient(baseURL: URL(string: "https://test.local")!, session: MockURLProtocol.mockSession()) 16 + vm = StoryCommentsViewModel(client: client) 17 + } 18 + 19 + override func tearDown() { 20 + MockURLProtocol.handler = nil 21 + super.tearDown() 22 + } 23 + 24 + private func makeDummyAuth() -> AuthContext { 25 + let key = P256.Signing.PrivateKey() 26 + let dpop = DPoP(privateKey: key) 27 + return AuthContext(accessToken: "test-token", dpop: dpop) 28 + } 29 + 30 + // MARK: - Preview Loading 31 + 32 + func testLoadPreviewSetsLatestCommentAndCount() async { 33 + MockURLProtocol.respondWithJSON(""" 34 + { 35 + "comments": [ 36 + { 37 + "uri": "at://did:plc:a/social.grain.comment/1", 38 + "cid": "c1", 39 + "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, 40 + "text": "Great shot!", 41 + "createdAt": "2024-06-15T12:00:00Z" 42 + } 43 + ], 44 + "totalCount": 5 45 + } 46 + """) 47 + 48 + await vm.loadPreview(storyUri: storyA) 49 + XCTAssertEqual(vm.latestComment?.text, "Great shot!") 50 + XCTAssertEqual(vm.totalCount, 5) 51 + } 52 + 53 + func testLoadPreviewCacheHitDoesNotFetchAgain() async { 54 + var requestCount = 0 55 + MockURLProtocol.handler = { request in 56 + requestCount += 1 57 + let json = """ 58 + {"comments": [{"uri": "at://did:plc:a/social.grain.comment/1", "cid": "c1", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Cached", "createdAt": "2024-06-15T12:00:00Z"}], "totalCount": 1} 59 + """ 60 + return (json.data(using: .utf8)!, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!) 61 + } 62 + 63 + await vm.loadPreview(storyUri: storyA) 64 + XCTAssertEqual(requestCount, 1) 65 + 66 + await vm.loadPreview(storyUri: storyA) 67 + XCTAssertEqual(requestCount, 1, "Second call should hit cache, not network") 68 + } 69 + 70 + func testLoadPreviewCacheMissFetchesBothStories() async { 71 + var requestCount = 0 72 + MockURLProtocol.handler = { request in 73 + requestCount += 1 74 + let json = """ 75 + {"comments": [{"uri": "at://did:plc:a/social.grain.comment/\\(requestCount)", "cid": "c\\(requestCount)", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Comment \\(requestCount)", "createdAt": "2024-06-15T12:00:00Z"}], "totalCount": \\(requestCount)} 76 + """ 77 + return (json.data(using: .utf8)!, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!) 78 + } 79 + 80 + await vm.loadPreview(storyUri: storyA) 81 + await vm.loadPreview(storyUri: storyB) 82 + XCTAssertEqual(requestCount, 2, "Different URIs should each trigger a request") 83 + } 84 + 85 + // MARK: - Full Comment Loading 86 + 87 + func testLoadCommentsPopulatesArray() async { 88 + MockURLProtocol.respondWithJSON(""" 89 + { 90 + "comments": [ 91 + {"uri": "at://did:plc:a/social.grain.comment/1", "cid": "c1", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Root comment", "createdAt": "2024-06-15T12:00:00Z"}, 92 + {"uri": "at://did:plc:a/social.grain.comment/2", "cid": "c2", "author": {"cid": "cb", "did": "did:plc:b", "handle": "bob.test"}, "text": "Reply", "replyTo": "at://did:plc:a/social.grain.comment/1", "createdAt": "2024-06-15T12:01:00Z"}, 93 + {"uri": "at://did:plc:a/social.grain.comment/3", "cid": "c3", "author": {"cid": "cc", "did": "did:plc:c", "handle": "carol.test"}, "text": "Another root", "createdAt": "2024-06-15T12:02:00Z"} 94 + ], 95 + "totalCount": 3 96 + } 97 + """) 98 + 99 + await vm.loadComments(storyUri: storyA) 100 + XCTAssertEqual(vm.comments.count, 3) 101 + XCTAssertEqual(vm.totalCount, 3) 102 + XCTAssertFalse(vm.isLoading) 103 + 104 + // Verify threading structure 105 + let roots = vm.comments.filter { $0.replyTo == nil } 106 + let replies = vm.comments.filter { $0.replyTo != nil } 107 + XCTAssertEqual(roots.count, 2) 108 + XCTAssertEqual(replies.count, 1) 109 + } 110 + 111 + func testLoadMoreCommentsAppends() async { 112 + // First load 113 + MockURLProtocol.respondWithJSON(""" 114 + {"comments": [{"uri": "at://did:plc:a/social.grain.comment/1", "cid": "c1", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "First", "createdAt": "2024-06-15T12:00:00Z"}], "cursor": "page2", "totalCount": 2} 115 + """) 116 + await vm.loadComments(storyUri: storyA) 117 + XCTAssertEqual(vm.comments.count, 1) 118 + 119 + // Paginate 120 + MockURLProtocol.respondWithJSON(""" 121 + {"comments": [{"uri": "at://did:plc:a/social.grain.comment/2", "cid": "c2", "author": {"cid": "cb", "did": "did:plc:b", "handle": "bob.test"}, "text": "Second", "createdAt": "2024-06-15T12:01:00Z"}]} 122 + """) 123 + await vm.loadMoreComments(storyUri: storyA) 124 + XCTAssertEqual(vm.comments.count, 2) 125 + } 126 + 127 + // MARK: - CRUD 128 + 129 + func testPostCommentRefreshesAndInvalidatesCache() async { 130 + // Seed cache 131 + MockURLProtocol.respondWithJSON(""" 132 + {"comments": [{"uri": "at://did:plc:a/social.grain.comment/old", "cid": "cold", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Old", "createdAt": "2024-06-15T12:00:00Z"}], "totalCount": 1} 133 + """) 134 + await vm.loadPreview(storyUri: storyA) 135 + XCTAssertEqual(vm.latestComment?.text, "Old") 136 + 137 + // Post triggers createRecord then loadComments refresh 138 + var requestPaths: [String] = [] 139 + MockURLProtocol.handler = { request in 140 + requestPaths.append(request.url?.lastPathComponent ?? "") 141 + let json = if request.httpMethod == "POST" { 142 + """ 143 + {"uri": "at://did:plc:test/social.grain.comment/new", "cid": "cnew"} 144 + """ 145 + } else { 146 + """ 147 + {"comments": [{"uri": "at://did:plc:a/social.grain.comment/new", "cid": "cnew", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Fresh", "createdAt": "2024-06-15T13:00:00Z"}], "totalCount": 2} 148 + """ 149 + } 150 + return (json.data(using: .utf8)!, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!) 151 + } 152 + 153 + let auth = makeDummyAuth() 154 + vm.switchToStory(uri: storyA) 155 + await vm.postComment(text: "Hello", storyUri: storyA, auth: auth) 156 + XCTAssertEqual(vm.latestComment?.text, "Fresh") 157 + XCTAssertEqual(vm.totalCount, 2) 158 + } 159 + 160 + func testDeleteCommentRemovesFromArray() async { 161 + // Switch first so activeStoryUri is set; the background preview task will fail silently without a mock 162 + vm.switchToStory(uri: storyA) 163 + try? await Task.sleep(for: .milliseconds(50)) 164 + 165 + MockURLProtocol.respondWithJSON(""" 166 + {"comments": [ 167 + {"uri": "at://did:plc:a/social.grain.comment/1", "cid": "c1", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Keep", "createdAt": "2024-06-15T12:00:00Z"}, 168 + {"uri": "at://did:plc:a/social.grain.comment/2", "cid": "c2", "author": {"cid": "cb", "did": "did:plc:b", "handle": "bob.test"}, "text": "Delete me", "createdAt": "2024-06-15T12:01:00Z"} 169 + ], "totalCount": 2} 170 + """) 171 + await vm.loadComments(storyUri: storyA) 172 + XCTAssertEqual(vm.comments.count, 2) 173 + 174 + // Delete the second comment 175 + MockURLProtocol.respondWithJSON("{}") 176 + let toDelete = vm.comments[1] 177 + let auth = makeDummyAuth() 178 + await vm.deleteComment(toDelete, storyUri: storyA, auth: auth) 179 + XCTAssertEqual(vm.comments.count, 1) 180 + XCTAssertEqual(vm.comments.first?.text, "Keep") 181 + XCTAssertEqual(vm.totalCount, 1) 182 + } 183 + 184 + // MARK: - Cache Switching 185 + 186 + func testSwitchToStoryCachedRestoresWithoutFetch() async { 187 + // Load preview for story A 188 + var requestCount = 0 189 + MockURLProtocol.handler = { request in 190 + requestCount += 1 191 + let json = """ 192 + {"comments": [{"uri": "at://did:plc:a/social.grain.comment/1", "cid": "c1", "author": {"cid": "ca", "did": "did:plc:a", "handle": "alice.test"}, "text": "Story A comment", "createdAt": "2024-06-15T12:00:00Z"}], "totalCount": 1} 193 + """ 194 + return (json.data(using: .utf8)!, HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: ["Content-Type": "application/json"])!) 195 + } 196 + 197 + vm.switchToStory(uri: storyA) 198 + // Wait for the background task to complete 199 + try? await Task.sleep(for: .milliseconds(100)) 200 + XCTAssertEqual(requestCount, 1) 201 + XCTAssertEqual(vm.latestComment?.text, "Story A comment") 202 + 203 + // Switch to story B 204 + vm.switchToStory(uri: storyB) 205 + try? await Task.sleep(for: .milliseconds(100)) 206 + XCTAssertEqual(requestCount, 2) 207 + 208 + // Switch back to A — should use cache 209 + vm.switchToStory(uri: storyA) 210 + // No sleep needed — cache is synchronous 211 + XCTAssertEqual(requestCount, 2, "Switching back to A should use cache") 212 + XCTAssertEqual(vm.latestComment?.text, "Story A comment") 213 + } 214 + }