iOS client for Grain
grain.social
ios
photography
atproto
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}