Skip to content

Commit 5b95727

Browse files
crazytanclaude
andcommitted
Add GoogleTasksAPI behavior tests with mock HTTP responses
Tests pagination (single/multi-page with nextPageToken), query parameter passing (showCompleted, showHidden, maxResults), authorization header, error handling (401 unauthorized, 500 server error, decoding error, network error), CRUD operations (create, update, delete, move), and listTaskLists (12 tests). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent a1d5367 commit 5b95727

File tree

1 file changed

+333
-0
lines changed

1 file changed

+333
-0
lines changed
Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import XCTest
2+
@testable import TaskMenu
3+
4+
/// Tests for GoogleTasksAPI behavior: pagination, parameter passing, error handling.
5+
/// Uses MockURLProtocol to simulate HTTP responses.
6+
/// Uses MockURLProtocol.requestLog to inspect requests (avoids captured var issues with Swift 6 concurrency).
7+
@MainActor
8+
final class GoogleTasksAPIBehaviorTests: XCTestCase {
9+
private var keychain: KeychainService!
10+
private var api: GoogleTasksAPI!
11+
12+
override func setUp() {
13+
super.setUp()
14+
MockURLProtocol.reset()
15+
16+
keychain = KeychainService(service: "com.taskmenu.apitest.\(UUID().uuidString)")
17+
// Pre-load valid tokens so validAccessToken() returns without refreshing
18+
try? keychain.save(key: Constants.Keychain.accessTokenKey, string: "test-token")
19+
try? keychain.save(key: Constants.Keychain.refreshTokenKey, string: "test-refresh")
20+
let futureExpiration = String(Date().addingTimeInterval(3600).timeIntervalSince1970)
21+
try? keychain.save(key: Constants.Keychain.expirationKey, string: futureExpiration)
22+
23+
let session = MockURLProtocol.mockSession()
24+
let authService = GoogleAuthService(keychain: keychain, session: session)
25+
api = GoogleTasksAPI(authService: authService, session: session)
26+
}
27+
28+
override func tearDown() {
29+
MockURLProtocol.reset()
30+
try? keychain.deleteAll()
31+
super.tearDown()
32+
}
33+
34+
// MARK: - Pagination
35+
36+
func testListTasksSinglePage() async throws {
37+
MockURLProtocol.requestHandler = { request in
38+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
39+
let json = #"{"items":[{"id":"t1","title":"Task 1","status":"needsAction"},{"id":"t2","title":"Task 2","status":"completed"}]}"#
40+
return (response, json.data(using: .utf8)!)
41+
}
42+
43+
let tasks = try await api.listTasks(listId: "list1")
44+
45+
XCTAssertEqual(tasks.count, 2)
46+
XCTAssertEqual(tasks[0].id, "t1")
47+
XCTAssertEqual(tasks[1].id, "t2")
48+
}
49+
50+
func testListTasksMultiplePagesFollowsNextPageToken() async throws {
51+
MockURLProtocol.requestHandler = { request in
52+
let url = request.url!.absoluteString
53+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
54+
55+
if url.contains("pageToken=page2") {
56+
let json = #"{"items":[{"id":"t3","title":"Task 3","status":"needsAction"}]}"#
57+
return (response, json.data(using: .utf8)!)
58+
} else {
59+
let json = #"{"items":[{"id":"t1","title":"Task 1","status":"needsAction"},{"id":"t2","title":"Task 2","status":"needsAction"}],"nextPageToken":"page2"}"#
60+
return (response, json.data(using: .utf8)!)
61+
}
62+
}
63+
64+
let tasks = try await api.listTasks(listId: "list1")
65+
66+
XCTAssertEqual(tasks.count, 3)
67+
XCTAssertEqual(tasks[0].id, "t1")
68+
XCTAssertEqual(tasks[1].id, "t2")
69+
XCTAssertEqual(tasks[2].id, "t3")
70+
// Verify two requests were made (page 1 + page 2)
71+
XCTAssertEqual(MockURLProtocol.requestLog.count, 2)
72+
}
73+
74+
func testListTasksEmptyResponse() async throws {
75+
MockURLProtocol.requestHandler = { request in
76+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
77+
return (response, #"{"kind":"tasks#tasks"}"#.data(using: .utf8)!)
78+
}
79+
80+
let tasks = try await api.listTasks(listId: "list1")
81+
82+
XCTAssertTrue(tasks.isEmpty)
83+
}
84+
85+
// MARK: - Parameter Passing
86+
87+
func testListTasksShowCompletedFalsePassesQueryParam() async throws {
88+
MockURLProtocol.requestHandler = { request in
89+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
90+
return (response, #"{"items":[]}"#.data(using: .utf8)!)
91+
}
92+
93+
_ = try await api.listTasks(listId: "list1", showCompleted: false, showHidden: false)
94+
95+
let url = MockURLProtocol.requestLog.last!.url!.absoluteString
96+
XCTAssertTrue(url.contains("showCompleted=false"))
97+
XCTAssertTrue(url.contains("showHidden=false"))
98+
}
99+
100+
func testListTasksShowCompletedTruePassesQueryParam() async throws {
101+
MockURLProtocol.requestHandler = { request in
102+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
103+
return (response, #"{"items":[]}"#.data(using: .utf8)!)
104+
}
105+
106+
_ = try await api.listTasks(listId: "list1", showCompleted: true, showHidden: true)
107+
108+
let url = MockURLProtocol.requestLog.last!.url!.absoluteString
109+
XCTAssertTrue(url.contains("showCompleted=true"))
110+
XCTAssertTrue(url.contains("showHidden=true"))
111+
}
112+
113+
func testListTasksMaxResultsParam() async throws {
114+
MockURLProtocol.requestHandler = { request in
115+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
116+
return (response, #"{"items":[]}"#.data(using: .utf8)!)
117+
}
118+
119+
_ = try await api.listTasks(listId: "list1")
120+
121+
let url = MockURLProtocol.requestLog.last!.url!.absoluteString
122+
XCTAssertTrue(url.contains("maxResults=100"))
123+
}
124+
125+
func testListTasksIncludesAuthorizationHeader() async throws {
126+
MockURLProtocol.requestHandler = { request in
127+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
128+
return (response, #"{"items":[]}"#.data(using: .utf8)!)
129+
}
130+
131+
_ = try await api.listTasks(listId: "list1")
132+
133+
let authHeader = MockURLProtocol.requestLog.last!.value(forHTTPHeaderField: "Authorization")
134+
XCTAssertEqual(authHeader, "Bearer test-token")
135+
}
136+
137+
// MARK: - Error Handling
138+
139+
func testListTasksThrowsUnauthorizedOn401() async {
140+
MockURLProtocol.requestHandler = { request in
141+
let response = HTTPURLResponse(url: request.url!, statusCode: 401, httpVersion: nil, headerFields: nil)!
142+
return (response, Data())
143+
}
144+
145+
do {
146+
_ = try await api.listTasks(listId: "list1")
147+
XCTFail("Expected unauthorized error")
148+
} catch let error as APIError {
149+
if case .unauthorized = error {
150+
// Expected
151+
} else {
152+
XCTFail("Expected .unauthorized, got \(error)")
153+
}
154+
} catch {
155+
XCTFail("Unexpected error type: \(error)")
156+
}
157+
}
158+
159+
func testListTasksThrowsServerErrorOn500() async {
160+
MockURLProtocol.requestHandler = { request in
161+
let response = HTTPURLResponse(url: request.url!, statusCode: 500, httpVersion: nil, headerFields: nil)!
162+
let body = "Internal Server Error"
163+
return (response, body.data(using: .utf8)!)
164+
}
165+
166+
do {
167+
_ = try await api.listTasks(listId: "list1")
168+
XCTFail("Expected server error")
169+
} catch let error as APIError {
170+
if case .serverError(let code, let message) = error {
171+
XCTAssertEqual(code, 500)
172+
XCTAssertEqual(message, "Internal Server Error")
173+
} else {
174+
XCTFail("Expected .serverError, got \(error)")
175+
}
176+
} catch {
177+
XCTFail("Unexpected error type: \(error)")
178+
}
179+
}
180+
181+
func testListTasksThrowsDecodingErrorOnBadJSON() async {
182+
MockURLProtocol.requestHandler = { request in
183+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
184+
return (response, "not json".data(using: .utf8)!)
185+
}
186+
187+
do {
188+
_ = try await api.listTasks(listId: "list1")
189+
XCTFail("Expected decoding error")
190+
} catch let error as APIError {
191+
if case .decodingError = error {
192+
// Expected
193+
} else {
194+
XCTFail("Expected .decodingError, got \(error)")
195+
}
196+
} catch {
197+
XCTFail("Unexpected error type: \(error)")
198+
}
199+
}
200+
201+
func testListTasksThrowsNetworkErrorOnURLError() async {
202+
MockURLProtocol.requestHandler = { _ in
203+
throw URLError(.notConnectedToInternet)
204+
}
205+
206+
do {
207+
_ = try await api.listTasks(listId: "list1")
208+
XCTFail("Expected network error")
209+
} catch let error as APIError {
210+
if case .networkError(let urlError) = error {
211+
XCTAssertEqual(urlError.code, .notConnectedToInternet)
212+
} else {
213+
XCTFail("Expected .networkError, got \(error)")
214+
}
215+
} catch {
216+
XCTFail("Unexpected error type: \(error)")
217+
}
218+
}
219+
220+
// MARK: - createTask
221+
222+
func testCreateTaskSendsPostWithJSON() async throws {
223+
MockURLProtocol.requestHandler = { request in
224+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
225+
let json = #"{"id":"new1","title":"Buy milk","status":"needsAction"}"#
226+
return (response, json.data(using: .utf8)!)
227+
}
228+
229+
let task = try await api.createTask(listId: "list1", title: "Buy milk")
230+
231+
XCTAssertEqual(task.id, "new1")
232+
XCTAssertEqual(task.title, "Buy milk")
233+
let lastRequest = MockURLProtocol.requestLog.last!
234+
XCTAssertEqual(lastRequest.httpMethod, "POST")
235+
XCTAssertEqual(lastRequest.value(forHTTPHeaderField: "Content-Type"), "application/json")
236+
}
237+
238+
func testCreateTaskWithNotesAndDue() async throws {
239+
MockURLProtocol.requestHandler = { request in
240+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
241+
let json = #"{"id":"new1","title":"Meeting","status":"needsAction","notes":"Room 3","due":"2026-04-01T00:00:00.000Z"}"#
242+
return (response, json.data(using: .utf8)!)
243+
}
244+
245+
let task = try await api.createTask(listId: "list1", title: "Meeting", notes: "Room 3", due: "2026-04-01T00:00:00.000Z")
246+
247+
XCTAssertEqual(task.id, "new1")
248+
XCTAssertEqual(task.title, "Meeting")
249+
XCTAssertEqual(task.notes, "Room 3")
250+
XCTAssertEqual(task.due, "2026-04-01T00:00:00.000Z")
251+
let lastRequest = MockURLProtocol.requestLog.last!
252+
XCTAssertEqual(lastRequest.httpMethod, "POST")
253+
XCTAssertEqual(lastRequest.value(forHTTPHeaderField: "Content-Type"), "application/json")
254+
}
255+
256+
// MARK: - deleteTask
257+
258+
func testDeleteTaskSendsDeleteMethod() async throws {
259+
MockURLProtocol.requestHandler = { request in
260+
let response = HTTPURLResponse(url: request.url!, statusCode: 204, httpVersion: nil, headerFields: nil)!
261+
return (response, Data())
262+
}
263+
264+
try await api.deleteTask(listId: "list1", taskId: "t1")
265+
266+
XCTAssertEqual(MockURLProtocol.requestLog.last!.httpMethod, "DELETE")
267+
}
268+
269+
// MARK: - updateTask
270+
271+
func testUpdateTaskSendsPatchMethod() async throws {
272+
MockURLProtocol.requestHandler = { request in
273+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
274+
let json = #"{"id":"t1","title":"Updated","status":"completed"}"#
275+
return (response, json.data(using: .utf8)!)
276+
}
277+
278+
let task = TaskItem(id: "t1", title: "Updated", notes: nil, status: .completed, due: nil, selfLink: nil, parent: nil, position: nil, updated: nil)
279+
let result = try await api.updateTask(listId: "list1", taskId: "t1", task: task)
280+
281+
XCTAssertEqual(MockURLProtocol.requestLog.last!.httpMethod, "PATCH")
282+
XCTAssertEqual(result.title, "Updated")
283+
XCTAssertTrue(result.isCompleted)
284+
}
285+
286+
// MARK: - listTaskLists
287+
288+
func testListTaskListsReturnsLists() async throws {
289+
MockURLProtocol.requestHandler = { request in
290+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
291+
let json = #"{"items":[{"id":"l1","title":"My Tasks"},{"id":"l2","title":"Work"}]}"#
292+
return (response, json.data(using: .utf8)!)
293+
}
294+
295+
let lists = try await api.listTaskLists()
296+
297+
XCTAssertEqual(lists.count, 2)
298+
XCTAssertEqual(lists[0].id, "l1")
299+
XCTAssertEqual(lists[1].title, "Work")
300+
XCTAssertTrue(MockURLProtocol.requestLog.last!.url!.absoluteString.contains("/users/@me/lists"))
301+
}
302+
303+
func testListTaskListsEmptyReturnsEmptyArray() async throws {
304+
MockURLProtocol.requestHandler = { request in
305+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
306+
return (response, #"{"kind":"tasks#taskLists"}"#.data(using: .utf8)!)
307+
}
308+
309+
let lists = try await api.listTaskLists()
310+
311+
XCTAssertTrue(lists.isEmpty)
312+
}
313+
314+
// MARK: - moveTask
315+
316+
func testMoveTaskSendsPostWithQueryParams() async throws {
317+
MockURLProtocol.requestHandler = { request in
318+
let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
319+
let json = #"{"id":"t1","title":"Moved","status":"needsAction"}"#
320+
return (response, json.data(using: .utf8)!)
321+
}
322+
323+
let result = try await api.moveTask(listId: "list1", taskId: "t1", previousId: "t0", parentId: "parent1")
324+
325+
let lastRequest = MockURLProtocol.requestLog.last!
326+
XCTAssertEqual(lastRequest.httpMethod, "POST")
327+
let url = lastRequest.url!.absoluteString
328+
XCTAssertTrue(url.contains("/move"))
329+
XCTAssertTrue(url.contains("previous=t0"))
330+
XCTAssertTrue(url.contains("parent=parent1"))
331+
XCTAssertEqual(result.id, "t1")
332+
}
333+
}

0 commit comments

Comments
 (0)