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.

at main 214 lines 9.8 kB view raw
1import CryptoKit 2@testable import Grain 3import XCTest 4 5@MainActor 6final 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.firstComment?.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.firstComment?.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.firstComment?.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.firstComment?.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.firstComment?.text, "Story A comment") 213 } 214}