NoteProjetSyncEngineTest.swift 186 lignes · 7411 octets
import XCTest
@testable import Card2vcf

/// Preuve de rougeur (exigence du plan `docs/plans/rattrapage-ios-et-tests.md`, § 2A) :
/// en neutralisant la garde LWW de `SyncEngine.applyNoteProjet`
/// (`guard remoteTs > localTs else { return }`), la suite passe de 204/0 à 204/3 échecs :
///   testPullNoteUpdatePlusRecentApplique   — XCTAssertEqual failed:
///       ("Optional("Nouveau titre")") is not equal to ("Optional("Ancien titre")")
///   testPullNoteUpdatePreserveAudioPath    — XCTAssertEqual failed:
///       ("Optional("Note vocale éditée")") is not equal to ("Optional("Note vocale")")
/// Ces tests détectent donc réellement la perte de la mise à jour LWW.
final class NoteProjetSyncEngineTest: XCTestCase {
    private var db: InMemoryCrmDatabase!
    private var api: FakeAilianceApi!
    private var engine: SyncEngine!

    override func setUp() {
        super.setUp()
        db = InMemoryCrmDatabase()
        api = FakeAilianceApi()
        engine = SyncEngine(api: api, db: db)
    }

    // MARK: - Insert

    func testPullNoteInsertNouvelle() async throws {
        api.pullResult = .ok(SyncPullResponse(
            serverTime: "2026-09-12T09:00:00Z",
            notes: [
                NoteProjetDto(
                    id: "n1", projetId: "p1",
                    titre: "Note initiale", contenu: "## Contenu",
                    creeLe: "2026-09-10T08:00:00Z"
                )
            ]
        ))

        _ = try await engine.syncNow()

        let stored = try await db.noteProjetDao.getByServerId("n1")
        XCTAssertNotNil(stored, "La note doit \u{ea}tre ins\u{e9}r\u{e9}e lors du pull")
        XCTAssertEqual("Note initiale", stored?.titre)
        XCTAssertEqual("## Contenu", stored?.texte)
        XCTAssertEqual("p1", stored?.projetServerId)
    }

    // MARK: - LWW update

    func testPullNoteUpdatePlusRecentApplique() async throws {
        // Note locale \u{e0} updatedAt = 1000 ms
        var local = NoteProjetEntity()
        local.serverId = "n2"; local.projetServerId = "p1"
        local.titre = "Ancien titre"; local.texte = "Ancien texte"; local.auteur = "alice"
        local.createdAt = 1_000; local.updatedAt = 1_000
        try await db.noteProjetDao.upsert(local)

        // Remote maj_le 2026-09-12 >> local 1000 ms
        api.pullResult = .ok(SyncPullResponse(
            serverTime: "2026-09-12T10:00:00Z",
            notes: [
                NoteProjetDto(
                    id: "n2", projetId: "p1",
                    titre: "Nouveau titre", contenu: "Nouveau texte",
                    auteur: "alice",
                    creeLe: "2026-09-10T08:00:00Z",
                    majLe: "2026-09-12T09:00:00Z"
                )
            ]
        ))

        _ = try await engine.syncNow()

        let stored = try await db.noteProjetDao.getByServerId("n2")
        XCTAssertEqual("Nouveau titre", stored?.titre, "La mise \u{e0} jour plus r\u{e9}cente doit \u{ea}tre appliqu\u{e9}e")
        XCTAssertEqual("Nouveau texte", stored?.texte)
    }

    func testPullNoteUpdatePlusAncienIgnore() async throws {
        // Note locale \u{e0} updatedAt tr\u{e8}s r\u{e9}cent (apr\u{e8}s 2030)
        var local = NoteProjetEntity()
        local.serverId = "n3"; local.projetServerId = "p1"
        local.titre = "Titre local"; local.texte = "Texte local"; local.auteur = "bob"
        local.createdAt = 2_000_000_000_000   // ~2033 en ms
        local.updatedAt = 2_000_000_000_000
        try await db.noteProjetDao.upsert(local)

        // Remote cree_le 2026 << local 2033
        api.pullResult = .ok(SyncPullResponse(
            serverTime: "2026-09-12T10:00:00Z",
            notes: [
                NoteProjetDto(
                    id: "n3", projetId: "p1",
                    titre: "Titre serveur", contenu: "Texte serveur",
                    creeLe: "2026-09-10T08:00:00Z"
                )
            ]
        ))

        _ = try await engine.syncNow()

        let stored = try await db.noteProjetDao.getByServerId("n3")
        XCTAssertEqual("Titre local", stored?.titre, "La mise \u{e0} jour plus ancienne NE doit PAS \u{ea}tre appliqu\u{e9}e")
    }

    // MARK: - audioPath pr\u{e9}serv\u{e9}

    func testPullNoteUpdatePreserveAudioPath() async throws {
        var local = NoteProjetEntity()
        local.serverId = "n4"; local.projetServerId = "p1"
        local.titre = "Note vocale"; local.texte = "Texte initial"; local.auteur = "alice"
        local.audioPath = "/local/notes/n4.wav"
        local.createdAt = 1_000; local.updatedAt = 1_000
        try await db.noteProjetDao.upsert(local)

        // Remote plus r\u{e9}cent, avec champ audio serveur (nom de fichier, pas chemin local)
        api.pullResult = .ok(SyncPullResponse(
            serverTime: "2026-09-12T10:00:00Z",
            notes: [
                NoteProjetDto(
                    id: "n4", projetId: "p1",
                    titre: "Note vocale \u{e9}dit\u{e9}e", contenu: "Texte \u{e9}dit\u{e9}",
                    creeLe: "2026-09-10T08:00:00Z",
                    majLe: "2026-09-12T09:00:00Z",
                    audio: "n4.wav"
                )
            ]
        ))

        _ = try await engine.syncNow()

        let stored = try await db.noteProjetDao.getByServerId("n4")
        XCTAssertEqual("Note vocale \u{e9}dit\u{e9}e", stored?.titre)
        XCTAssertEqual("/local/notes/n4.wav", stored?.audioPath,
                       "audioPath local doit \u{ea}tre pr\u{e9}serv\u{e9} lors d'une mise \u{e0} jour serveur")
    }

    // MARK: - Tombstone

    func testPullTombstoneNoteSupprimeNote() async throws {
        var local = NoteProjetEntity()
        local.serverId = "n-del"; local.projetServerId = "p1"
        local.titre = "\u{e0} supprimer"; local.texte = ""; local.auteur = ""; local.createdAt = 1
        try await db.noteProjetDao.upsert(local)

        api.pullResult = .ok(SyncPullResponse(
            serverTime: "2026-09-12T10:00:00Z",
            tombstones: [
                TombstoneDto(entityType: "note", id: "n-del", supprimeLe: "2026-09-12T10:00:00Z")
            ]
        ))

        _ = try await engine.syncNow()

        let stored = try await db.noteProjetDao.getByServerId("n-del")
        XCTAssertNil(stored, "La note supprim\u{e9}e par tombstone doit disparaitre")
    }

    // MARK: - R\u{e9}ponse sans section notes

    func testPullSansNotesNePasPlanter() async throws {
        // Serveur antérieur : aucun champ "notes" dans la réponse
        api.pullResult = .ok(SyncPullResponse(serverTime: "2026-09-12T10:00:00Z"))

        let result = try await engine.syncNow()

        XCTAssertTrue(result.success)
        let notes = try await db.noteProjetDao.listAll()
        XCTAssertTrue(notes.isEmpty)
    }

    // MARK: - Comptage dans received

    func testPullNotesCompteesDansReceived() async throws {
        api.pullResult = .ok(SyncPullResponse(
            serverTime: "2026-09-12T10:00:00Z",
            notes: [
                NoteProjetDto(id: "n1", projetId: "p1", titre: "A", contenu: "a", creeLe: "2026-09-10T08:00:00Z"),
                NoteProjetDto(id: "n2", projetId: "p1", titre: "B", contenu: "b", creeLe: "2026-09-10T08:00:00Z"),
            ]
        ))

        let result = try await engine.syncNow()

        XCTAssertEqual(2, result.received, "Les notes doivent \u{ea}tre compt\u{e9}es dans received")
    }
}