AilianceApiClientTest.swift
428 lignes · 15047 octets
import XCTest @testable import Card2vcf /// URLProtocol-based mock server (port of the MockWebServer scenarios). final class MockURLProtocol: URLProtocol { struct StubResponse { var statusCode: Int var headers: [String: String] = [:] var body: Data = Data() } struct RecordedRequest { var request: URLRequest var body: Data? var method: String? { request.httpMethod } /// Path + query, percent-encoding preserved (MockWebServer `request.path` equivalent). var path: String? { guard let url = request.url else { return nil } let absolute = url.absoluteString guard let hostRange = absolute.range(of: url.host ?? "") else { return absolute } var tail = String(absolute[hostRange.upperBound...]) if let port = url.port, tail.hasPrefix(":\(port)") { tail.removeFirst(":\(port)".count) } return tail } func header(_ name: String) -> String? { request.value(forHTTPHeaderField: name) } } static var queue: [StubResponse] = [] static var recorded: [RecordedRequest] = [] static func reset() { queue = [] recorded = [] } static func enqueue(_ statusCode: Int, body: String, headers: [String: String] = [:]) { queue.append(StubResponse(statusCode: statusCode, headers: headers, body: Data(body.utf8))) } static func enqueue(_ statusCode: Int, data: Data, headers: [String: String] = [:]) { queue.append(StubResponse(statusCode: statusCode, headers: headers, body: data)) } override class func canInit(with request: URLRequest) -> Bool { true } override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } override func startLoading() { MockURLProtocol.recorded.append( RecordedRequest(request: request, body: Self.drainBody(request)) ) let stub: StubResponse if MockURLProtocol.queue.isEmpty { stub = StubResponse(statusCode: 500, body: Data("no stubbed response".utf8)) } else { stub = MockURLProtocol.queue.removeFirst() } let response = HTTPURLResponse( url: request.url!, statusCode: stub.statusCode, httpVersion: "HTTP/1.1", headerFields: stub.headers )! client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) client?.urlProtocol(self, didLoad: stub.body) client?.urlProtocolDidFinishLoading(self) } override func stopLoading() {} private static func drainBody(_ request: URLRequest) -> Data? { if let body = request.httpBody { return body } guard let stream = request.httpBodyStream else { return nil } stream.open() defer { stream.close() } var data = Data() let bufferSize = 4096 var buffer = [UInt8](repeating: 0, count: bufferSize) while stream.hasBytesAvailable { let read = stream.read(&buffer, maxLength: bufferSize) if read <= 0 { break } data.append(buffer, count: read) } return data } } final class AilianceApiClientTest: XCTestCase { private static let baseUrl = "http://mock.local" private var client: AilianceApiClient! override func setUp() { super.setUp() MockURLProtocol.reset() client = AilianceApiClient( baseUrl: Self.baseUrl, apiKey: "test-bearer-key", session: Self.mockSession() ) } override func tearDown() { MockURLProtocol.reset() super.tearDown() } private static func mockSession() -> URLSession { let config = URLSessionConfiguration.ephemeral config.protocolClasses = [MockURLProtocol.self] return URLSession(configuration: config) } private func takeRequest(_ index: Int = 0) throws -> MockURLProtocol.RecordedRequest { try XCTUnwrap( MockURLProtocol.recorded.indices.contains(index) ? MockURLProtocol.recorded[index] : nil ) } func testAuthCle200ParsesResponse() async throws { MockURLProtocol.enqueue( 200, body: """ { "nom": "alice", "cle_chiffree": "Y2lw", "sel": "c2Vs", "kdf": "argon2id", "cipher": "xchacha20poly1305" } """ ) let result = await client.authCle(nom: "alice", motDePasse: "secret") guard case .ok(let value) = result else { return XCTFail("expected Ok, got \(result)") } XCTAssertEqual("alice", value.nom) XCTAssertEqual("Y2lw", value.cleChiffree) XCTAssertEqual("c2Vs", value.sel) XCTAssertEqual("argon2id", value.kdf) XCTAssertEqual("xchacha20poly1305", value.cipher) let request = try takeRequest() XCTAssertEqual("POST", request.method) XCTAssertEqual("/api/auth/cle", request.path) XCTAssertNil(request.header("Authorization")) } func testAuthCle401ReturnsErr() async throws { MockURLProtocol.enqueue(401, body: #"{"message":"Identifiants invalides."}"#) let result = await client.authCle(nom: "alice", motDePasse: "wrong") guard case .err(let code, let message) = result else { return XCTFail("expected Err, got \(result)") } XCTAssertEqual(401, code) XCTAssertEqual("Identifiants invalides.", message) } func testSyncStatus200ParsesTotalAndChanges() async throws { MockURLProtocol.enqueue( 200, body: """ { "server_time": "2026-07-22T12:00:00Z", "changes": { "contacts": 2, "entreprises": 0, "projets": 1, "taches": 3, "interactions": 1, "tombstones": 1 }, "total": 8 } """ ) let result = await client.syncStatus(sinceIso: "1970-01-01T00:00:00Z") guard case .ok(let value) = result else { return XCTFail("expected Ok, got \(result)") } XCTAssertEqual("2026-07-22T12:00:00Z", value.serverTime) XCTAssertEqual(8, value.total) XCTAssertEqual(2, value.changes.contacts) XCTAssertEqual(1, value.changes.tombstones) let request = try takeRequest() XCTAssertEqual("GET", request.method) XCTAssertEqual("/api/sync/status?since=1970-01-01T00%3A00%3A00Z", request.path) XCTAssertEqual("Bearer test-bearer-key", request.header("Authorization")) } func testSyncPull200ParsesContactsAndTombstones() async throws { MockURLProtocol.enqueue( 200, body: """ { "server_time": "2026-07-22T12:05:00Z", "contacts": [ { "id": "c1", "prenom": "Jean", "nom": "Dupont", "cree_le": "2026-07-20T10:00:00Z", "mis_a_jour_le": "2026-07-21T08:00:00Z" } ], "entreprises": [], "projets": [], "taches": [ { "projet_id": "p1", "id": "t1", "titre": "Appeler client", "colonne_id": "col-a-faire", "ordre": 0, "cree_le": "2026-07-21T09:00:00Z", "assigne_a": "alice" } ], "interactions": [], "tombstones": [ { "type": "contact", "id": "c-old", "supprime_le": "2026-07-22T11:00:00Z" } ], "workflows": [] } """ ) let result = await client.syncPull(sinceIso: "2026-07-20T00:00:00Z") guard case .ok(let value) = result else { return XCTFail("expected Ok, got \(result)") } XCTAssertEqual(1, value.contacts.count) XCTAssertEqual("c1", value.contacts[0].id) XCTAssertEqual("Jean", value.contacts[0].prenom) XCTAssertEqual(1, value.taches.count) XCTAssertEqual("p1", value.taches[0].projetId) XCTAssertEqual("alice", value.taches[0].assigneA) XCTAssertEqual(1, value.tombstones.count) XCTAssertEqual("contact", value.tombstones[0].entityType) XCTAssertEqual("c-old", value.tombstones[0].id) } func testSyncPull200ParsesAgendaFields() async throws { MockURLProtocol.enqueue( 200, body: """ { "server_time": "2026-07-22T12:05:00Z", "contacts": [], "entreprises": [], "projets": [], "taches": [], "interactions": [], "rdv": [ { "id": "rdv1", "titre": "Point client", "utilisateur": "alice", "debut": "2026-07-22T09:00:00Z", "fin": "2026-07-22T10:00:00Z", "cree_par": "alice", "cree_le": "2026-07-20T08:00:00Z" } ], "reservations": [ { "id": "resa1", "cible": {"type": "salle", "id": "salle-a"}, "debut": "2026-07-22T09:00:00Z", "fin": "2026-07-22T10:00:00Z", "motif": "Réunion", "statut": "active", "reserve_par": "alice", "cree_le": "2026-07-20T08:00:00Z" } ], "indisponibilites": [ { "id": "indispo1", "cible": {"type": "vehicule", "id": "vehicule-a"}, "nature": "entretien", "debut": "2026-07-22T09:00:00Z", "fin": "2026-07-22T10:00:00Z", "cree_par": "alice", "cree_le": "2026-07-20T08:00:00Z" } ], "tombstones": [], "workflows": [] } """ ) let result = await client.syncPull(sinceIso: "2026-07-20T00:00:00Z") guard case .ok(let value) = result else { return XCTFail("expected Ok, got \(result)") } XCTAssertEqual(1, value.rdv.count) XCTAssertEqual("Point client", value.rdv[0].titre) XCTAssertEqual(1, value.reservations.count) XCTAssertEqual("salle", value.reservations[0].cible.type) XCTAssertEqual("salle-a", value.reservations[0].cible.id) XCTAssertEqual(1, value.indisponibilites.count) XCTAssertEqual("entretien", value.indisponibilites[0].nature) } func testCreateReservation409ReturnsErrWithCode() async throws { MockURLProtocol.enqueue( 409, body: #"{"message":"Réservation refusée : période en conflit.","reclamable":true}"# ) let result = await client.createReservation( genre: "salle", resourceId: "salle-a", jsonBody: #"{"debut":"2026-07-22T09:00:00Z","fin":"2026-07-22T10:00:00Z","motif":"Réunion"}"# ) guard case .err(let code, let message) = result else { return XCTFail("expected Err, got \(result)") } XCTAssertEqual(409, code) XCTAssertEqual("Réservation refusée : période en conflit.", message) let request = try takeRequest() XCTAssertEqual("POST", request.method) XCTAssertEqual("/api/ressources/salle/salle-a/reservations", request.path) } func testGetRessourcesCatalogue200ParsesCatalogue() async throws { MockURLProtocol.enqueue( 200, body: """ { "salles": [{"id": "salle-a", "nom": "Salle A", "actif": true}], "materiels": [], "vehicules": [{"id": "vehicule-a", "nom": "Véhicule A", "actif": false}] } """ ) let result = await client.getRessourcesCatalogue() guard case .ok(let value) = result else { return XCTFail("expected Ok, got \(result)") } XCTAssertEqual(1, value.salles.count) XCTAssertEqual("Salle A", value.salles[0].nom) XCTAssertTrue(value.salles[0].actif) XCTAssertEqual(0, value.materiels.count) XCTAssertEqual(1, value.vehicules.count) XCTAssertFalse(value.vehicules[0].actif) let request = try takeRequest() XCTAssertEqual("GET", request.method) XCTAssertEqual("/api/ressources", request.path) } func testProtectedEndpointWithoutBearerReturns401() async throws { let unauthenticated = AilianceApiClient( baseUrl: Self.baseUrl, apiKey: nil, session: Self.mockSession() ) MockURLProtocol.enqueue(401, body: #"{"message":"Non autorisé."}"#) let result = await unauthenticated.syncStatus(sinceIso: "1970-01-01T00:00:00Z") guard case .err(let code, _) = result else { return XCTFail("expected Err, got \(result)") } XCTAssertEqual(401, code) let request = try takeRequest() XCTAssertNil(request.header("Authorization")) } func testUploadContactCarte_sendsMultipartWithBearer() async throws { MockURLProtocol.enqueue( 200, body: #"{"id":"c1","carte_visite":"carte.jpg","cree_le":"2026-07-22T00:00:00Z"}"# ) let result = await client.uploadContactCarte(id: "c1", bytes: Data([1, 2, 3]), filename: "card.jpg") XCTAssertTrue(result.isOk) let request = try takeRequest() XCTAssertEqual("POST", request.method) XCTAssertEqual("/api/contacts/c1/carte", request.path) XCTAssertEqual("Bearer test-bearer-key", request.header("Authorization")) let body = String(data: try XCTUnwrap(request.body), encoding: .utf8) ?? "" XCTAssertTrue(body.contains("filename=\"card.jpg\"")) XCTAssertTrue(try XCTUnwrap(request.header("Content-Type")).hasPrefix("multipart/form-data")) } func testDownloadContactCarte_returnsBytes() async throws { MockURLProtocol.enqueue(200, data: Data([10, 20, 30]), headers: ["Content-Type": "image/jpeg"]) let result = await client.downloadContactCarte(id: "c1") guard case .ok(let bytes) = result else { return XCTFail("expected Ok, got \(result)") } XCTAssertEqual(Data([10, 20, 30]), bytes) let request = try takeRequest() XCTAssertEqual("GET", request.method) XCTAssertEqual("/api/contacts/c1/carte", request.path) } }
GitRust