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)
    }
}