diff --git a/InterlinedList/Services/APIClient.swift b/InterlinedList/Services/APIClient.swift index 70d9769..af7d760 100644 --- a/InterlinedList/Services/APIClient.swift +++ b/InterlinedList/Services/APIClient.swift @@ -206,31 +206,37 @@ final class APIClient { // MARK: - Avatar upload (Phase 3 — sister agent dependency) + /// Both avatar routes persist the new avatar themselves + /// (`prisma.user.update`) and answer with `{ url, user }`, so the saved user + /// comes straight off the upload response — there is no follow-up write. An + /// earlier version POSTed `/api/user/update` to apply the URL, which the + /// route rejects with **405** (it exports `PATCH` only): the avatar landed on + /// the server but the app surfaced an error and never refreshed the local + /// `User`, so the change only appeared after a relaunch. func uploadAvatar(data: Data, mimeType: String) async throws -> User { let ext = mimeType == "image/png" ? "png" : "jpg" let responseData = try await postMultipartRawData( "/api/user/avatar/upload", fileName: "avatar.\(ext)", mimeType: mimeType, fileData: data ) - struct UploadResp: Decodable { let url: String? } - if let avatarUrl = (try? decoder.decode(UploadResp.self, from: responseData))?.url { - return try await applyAvatarUrl(avatarUrl) + if let user = (try? decoder.decode(AvatarResponse.self, from: responseData))?.user { + return user } return try await currentUser() } func setAvatarFromURL(_ avatarUrl: String) async throws -> User { struct Body: Encodable { let url: String } - struct Response: Decodable { let url: String? } - let resp: Response = try await post("/api/user/avatar/from-url", body: Body(url: avatarUrl)) - return try await applyAvatarUrl(resp.url ?? avatarUrl) + let response: AvatarResponse = try await post("/api/user/avatar/from-url", body: Body(url: avatarUrl)) + if let user = response.user { return user } + return try await currentUser() } - private func applyAvatarUrl(_ url: String) async throws -> User { - struct Body: Encodable { let avatar: String } - struct Resp: Decodable { let user: User? } - let wrapped: Resp = try await post("/api/user/update", body: Body(avatar: url)) - if let user = wrapped.user { return user } - return try await currentUser() + /// The shape both avatar routes answer with. `user` is optional only to + /// tolerate an older deployment that sent the bare `{ url }`; the fallback + /// re-reads the profile rather than leaving the caller a stale avatar. + private struct AvatarResponse: Decodable { + let url: String? + let user: User? } // MARK: - Organizations (Phase 3 — sister agent dependency) diff --git a/InterlinedListTests/APIClientTests/APIClientAvatarTests.swift b/InterlinedListTests/APIClientTests/APIClientAvatarTests.swift index 374a307..53927c1 100644 --- a/InterlinedListTests/APIClientTests/APIClientAvatarTests.swift +++ b/InterlinedListTests/APIClientTests/APIClientAvatarTests.swift @@ -1,6 +1,10 @@ import XCTest @testable import InterlinedList +/// D4. Both avatar routes persist the avatar themselves and answer with +/// `{ url, user }`, so each call is a **single** request. The previous shape — +/// upload, then `POST /api/user/update` to apply the URL — returned 405 from the +/// PATCH-only route, so these tests pin the request count as much as the result. final class APIClientAvatarTests: XCTestCase { var sut: APIClient! var session: MockURLSession! @@ -12,32 +16,46 @@ final class APIClientAvatarTests: XCTestCase { sut.setBearerToken("tok") } - private var userJSON: String { - #"{"user":{"id":"u1","email":"a@b.com","username":"alice","avatar":"https://cdn/avatar.png"}}"# + /// The live shape: the saved avatar URL plus the updated user record. + private var avatarJSON: String { + #""" + {"url":"https://cdn/avatar.png", + "user":{"id":"u1","email":"a@b.com","username":"alice","avatar":"https://cdn/avatar.png"}} + """# } // MARK: uploadAvatar func test_uploadAvatar_sendsPostToCorrectPath() async throws { - session.enqueue(json: #"{"url":"https://cdn/avatar.png"}"#) - session.enqueue(json: userJSON) + session.stub(json: avatarJSON) _ = try await sut.uploadAvatar(data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") - // First request is the upload; track via requestHistory. XCTAssertEqual(session.requestHistory.first?.httpMethod, "POST") XCTAssertEqual(session.requestHistory.first?.url?.path, "/api/user/avatar/upload") } + func test_uploadAvatar_makesExactlyOneRequest() async throws { + session.stub(json: avatarJSON) + _ = try await sut.uploadAvatar(data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + XCTAssertEqual(session.requestHistory.count, 1, + "The upload response already carries the saved user — a second write 405s") + } + + func test_uploadAvatar_neverPostsToUserUpdate() async throws { + session.stub(json: avatarJSON) + _ = try await sut.uploadAvatar(data: Data([0xFF, 0xD8]), mimeType: "image/jpeg") + XCTAssertFalse(session.requestHistory.contains { $0.url?.path == "/api/user/update" }, + "/api/user/update exports PATCH only; POSTing it returns 405") + } + func test_uploadAvatar_usesMultipart() async throws { - session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) - session.enqueue(json: userJSON) + session.stub(json: avatarJSON) _ = try await sut.uploadAvatar(data: Data([0xFF]), mimeType: "image/png") let ct = session.requestHistory.first?.value(forHTTPHeaderField: "Content-Type") ?? "" XCTAssertTrue(ct.hasPrefix("multipart/form-data")) } func test_uploadAvatar_pngUsesPngExtension() async throws { - session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) - session.enqueue(json: userJSON) + session.stub(json: avatarJSON) _ = try await sut.uploadAvatar(data: Data([0x89]), mimeType: "image/png") // The multipart body carries raw (non-UTF8) image bytes, so search the raw // Data for the filename rather than decoding the whole body as a String. @@ -46,14 +64,24 @@ final class APIClientAvatarTests: XCTestCase { "Multipart body should declare a .png filename") } - func test_uploadAvatar_returnsUser() async throws { - session.enqueue(json: #"{"url":"https://cdn/x.jpg"}"#) - session.enqueue(json: userJSON) + func test_uploadAvatar_returnsUserFromUploadResponse() async throws { + session.stub(json: avatarJSON) let user = try await sut.uploadAvatar(data: Data([0xFF]), mimeType: "image/jpeg") XCTAssertEqual(user.id, "u1") XCTAssertEqual(user.avatar, "https://cdn/avatar.png") } + /// Tolerates a deployment that answers with the bare `{ url }`: fall back to + /// re-reading the profile rather than returning a stale avatar. + func test_uploadAvatar_withoutUserInResponse_fallsBackToCurrentUser() async throws { + session.enqueue(json: #"{"url":"https://cdn/x.jpg"}"#) + session.enqueue(json: #"{"user":{"id":"u1","email":"a@b.com","username":"alice","avatar":"https://cdn/x.jpg"}}"#) + let user = try await sut.uploadAvatar(data: Data([0xFF]), mimeType: "image/jpeg") + XCTAssertEqual(user.avatar, "https://cdn/x.jpg") + XCTAssertEqual(session.requestHistory.count, 2) + XCTAssertEqual(session.requestHistory.last?.url?.path, "/api/user") + } + func test_uploadAvatar_403_throws() async throws { session.stub(data: Data(), statusCode: 403) do { @@ -67,25 +95,39 @@ final class APIClientAvatarTests: XCTestCase { // MARK: setAvatarFromURL func test_setAvatarFromURL_sendsCorrectPath() async throws { - session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) - session.enqueue(json: userJSON) + session.stub(json: avatarJSON) _ = try await sut.setAvatarFromURL("https://external/img.png") XCTAssertEqual(session.requestHistory.first?.url?.path, "/api/user/avatar/from-url") XCTAssertEqual(session.requestHistory.first?.httpMethod, "POST") } + func test_setAvatarFromURL_makesExactlyOneRequest() async throws { + session.stub(json: avatarJSON) + _ = try await sut.setAvatarFromURL("https://external/img.png") + XCTAssertEqual(session.requestHistory.count, 1) + XCTAssertFalse(session.requestHistory.contains { $0.url?.path == "/api/user/update" }) + } + func test_setAvatarFromURL_bodyContainsURL() async throws { - session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) - session.enqueue(json: userJSON) + session.stub(json: avatarJSON) _ = try await sut.setAvatarFromURL("https://external/img.png") let body = String(data: session.requestHistory.first?.httpBody ?? Data(), encoding: .utf8) ?? "" XCTAssertTrue(body.contains("\"url\":\"https:\\/\\/external\\/img.png\"")) } - func test_setAvatarFromURL_returnsUser() async throws { - session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) - session.enqueue(json: userJSON) + func test_setAvatarFromURL_returnsUserFromResponse() async throws { + session.stub(json: avatarJSON) let user = try await sut.setAvatarFromURL("https://external/img.png") XCTAssertEqual(user.id, "u1") + XCTAssertEqual(user.avatar, "https://cdn/avatar.png") + } + + func test_setAvatarFromURL_withoutUserInResponse_fallsBackToCurrentUser() async throws { + session.enqueue(json: #"{"url":"https://cdn/x.png"}"#) + session.enqueue(json: #"{"user":{"id":"u1","email":"a@b.com","username":"alice","avatar":"https://cdn/x.png"}}"#) + let user = try await sut.setAvatarFromURL("https://external/img.png") + XCTAssertEqual(user.avatar, "https://cdn/x.png") + XCTAssertEqual(session.requestHistory.count, 2) + XCTAssertEqual(session.requestHistory.last?.url?.path, "/api/user") } }