AgendaSyncEngineTest.swift 503 lignes · 21874 octets
import XCTest
@testable import Card2vcf

/// Task M5 (Android): LWW RDV, reservation 409 conflict, abandon, `ressourcesQuery`,
/// calendar↔store bridge.
final class AgendaSyncEngineTest: 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)
    }

    private func epoch(_ iso: String) -> Int64 {
        parseIsoToEpochMs(iso)
    }

    // ---- LWW RDV ----

    func testSyncNow_rdvPullNewerServerOverwritesCleanLocal() async throws {
        var local = RdvEntity()
        local.serverId = "rdv1"
        local.titre = "Ancien titre"
        local.updatedAt = epoch("2026-07-20T10:00:00Z")
        _ = try await db.rdvDao.upsert(local)
        api.pullResult = .ok(
            SyncPullResponse(
                serverTime: "2026-07-22T12:00:00Z",
                rdv: [
                    RendezVousDto(
                        id: "rdv1",
                        titre: "Nouveau titre serveur",
                        debut: "2026-07-22T09:00:00Z",
                        fin: "2026-07-22T10:00:00Z",
                        creeLe: "2026-07-20T10:00:00Z",
                        misAJourLe: "2026-07-22T10:00:00Z"
                    ),
                ]
            )
        )

        _ = try await engine.syncNow()

        let updated = try await db.rdvDao.getByServerId("rdv1")
        XCTAssertEqual("Nouveau titre serveur", updated?.titre)
    }

    func testSyncNow_rdvPullDirtyLocalNewerKeepsLocal() async throws {
        var local = RdvEntity()
        local.serverId = "rdv2"
        local.titre = "Titre local en avance"
        local.updatedAt = epoch("2026-07-22T10:00:00Z")
        local.dirtyLocal = true
        _ = try await db.rdvDao.upsert(local)
        api.pullResult = .ok(
            SyncPullResponse(
                serverTime: "2026-07-22T12:00:00Z",
                rdv: [
                    RendezVousDto(
                        id: "rdv2",
                        titre: "Titre serveur périmé",
                        debut: "2026-07-19T09:00:00Z",
                        fin: "2026-07-19T10:00:00Z",
                        creeLe: "2026-07-18T10:00:00Z",
                        misAJourLe: "2026-07-20T10:00:00Z"
                    ),
                ]
            )
        )

        _ = try await engine.syncNow()

        let kept = try await db.rdvDao.getByServerId("rdv2")
        XCTAssertEqual("Titre local en avance", kept?.titre)
        XCTAssertEqual(true, kept?.dirtyLocal)
    }

    // ---- Conflit 409 réservation ----

    func testSyncNow_createReservation409SetsConflictPendingAndKeepsSyncOp() async throws {
        var reservation = ReservationEntity()
        reservation.cibleType = "salle"
        reservation.cibleId = "salle-1"
        reservation.motif = "Réunion"
        reservation.dirtyLocal = true
        let localId = try await db.reservationDao.upsert(reservation)
        var op = SyncOpEntity(entityType: "reservation", op: "create")
        op.payloadJson = #"{"debut":"2026-07-22T09:00:00Z","fin":"2026-07-22T10:00:00Z"}"#
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)
        api.createReservationResult = .err(code: 409, message: "Chevauchement avec une autre réservation")

        let result = try await engine.syncNow()

        XCTAssertFalse(result.conflicts.isEmpty)
        XCTAssertEqual(localId, result.conflicts.first?.localReservationId)
        // Le 409 réservation reste arbitré par la dialog dédiée : pas d'échec générique dans le résumé.
        XCTAssertTrue(result.pushFailures.isEmpty)
        XCTAssertEqual(0, result.pushed)
        let stored = try await db.reservationDao.getByLocalId(localId)
        XCTAssertEqual(true, stored?.conflictPending)
        let pending = try await db.syncOpDao.listAll()
        XCTAssertEqual(1, pending.count)
        XCTAssertEqual(1, api.createReservationCalls.count)
        XCTAssertEqual("salle", api.createReservationCalls[0].0)
        XCTAssertEqual("salle-1", api.createReservationCalls[0].1)
    }

    // ---- Motif d'une réservation créée dans l'app Calendrier ----

    func testAgendaEventCree_pousseLeTitreCommeMotif() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "PicLead — Véhicule kangoo")
        // Cas réel : l'app Calendrier remplit le titre, pas la description.
        bridge.seedEvent(
            calendarId: calendarId,
            snap: CalendarEventSnapshot(title: "Course fournisseur", debutMs: 1_000, finMs: 2_000)
        )
        let bindings = [
            CalendarBinding(kind: "vehicule", serverResourceId: "v1", displayName: "Kangoo", androidCalendarId: calendarId),
        ]

        _ = try await engine.syncNow(bindings: bindings, bridge: bridge)

        let stored = try await db.reservationDao.listByCible(cibleType: "vehicule", cibleId: "v1")
        XCTAssertEqual(1, stored.count)
        XCTAssertEqual("Course fournisseur", stored.first?.motif)
        XCTAssertEqual(1, api.createReservationCalls.count)
        let corps = api.createReservationCalls[0].2
        XCTAssertTrue(corps.contains(#""motif":"Course fournisseur""#), "motif attendu dans \(corps)")
    }

    // ---- Abandon ----

    func testAbandonLocalReservation_removesEntitySyncOpsAndCalendarEvent() async throws {
        var reservation = ReservationEntity()
        reservation.cibleType = "salle"
        reservation.cibleId = "salle-1"
        reservation.motif = "Réunion"
        reservation.conflictPending = true
        reservation.calendarEventId = 42
        let localId = try await db.reservationDao.upsert(reservation)
        var op = SyncOpEntity(entityType: "reservation", op: "create")
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)
        let bridge = FakeCalendarBridge()

        try await engine.abandonLocalReservation(localId: localId, bridge: bridge)

        let stored = try await db.reservationDao.getByLocalId(localId)
        XCTAssertNil(stored)
        let pending = try await db.syncOpDao.listAll()
        XCTAssertTrue(pending.isEmpty)
        XCTAssertTrue(bridge.deletedEventIds.contains(42))
    }

    // ---- ressourcesQuery ----

    func testRessourcesQuery_buildsFromNonRdvBindingsWithServerResourceId() {
        let coordinator = AgendaSyncCoordinator(db: db)
        let bindings = [
            CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: 1),
            CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: 2),
            CalendarBinding(kind: "materiel", serverResourceId: "m1", displayName: "Matériel B", androidCalendarId: 3),
            CalendarBinding(kind: "vehicule", serverResourceId: nil, displayName: "Sans id serveur", androidCalendarId: 4),
        ]

        XCTAssertEqual("salle:s1,materiel:m1", coordinator.ressourcesQuery(bindings))
    }

    func testRessourcesQuery_emptyBindingsReturnsNil() {
        let coordinator = AgendaSyncCoordinator(db: db)

        XCTAssertNil(coordinator.ressourcesQuery([]))
    }

    func testSyncNow_pullUsesRessourcesQueryDerivedFromBindings() async throws {
        let bridge = FakeCalendarBridge()
        let rdvCalendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
        let salleCalendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A")
        let bindings = [
            CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: rdvCalendarId),
            CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: salleCalendarId),
        ]

        _ = try await engine.syncNow(bindings: bindings, bridge: bridge)

        XCTAssertEqual("salle:s1", api.pullQueries.last ?? nil)
    }

    func testCheckStatus_forwardsRessourcesQueryToApi() async throws {
        _ = try await engine.checkStatus(ressourcesQuery: "salle:s1")

        XCTAssertEqual("salle:s1", api.statusQueries.last ?? nil)
    }

    func testSyncNow_withoutBindingsOrBridgeOmitsRessourcesQuery() async throws {
        _ = try await engine.syncNow()

        XCTAssertNil(api.pullQueries.last ?? nil)
    }

    // ---- Agenda -> Room / Room -> Agenda ----

    func testSyncNow_agendaToRoomCreatesRdvFromNewCalendarEventAndPushesIt() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
        bridge.seedEvent(
            calendarId: calendarId,
            snap: CalendarEventSnapshot(title: "RDV client", debutMs: 1_000, finMs: 2_000, descriptionBody: "notes")
        )
        let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]

        _ = try await engine.syncNow(bindings: bindings, bridge: bridge)

        let stored = try await db.rdvDao.listAll()
        XCTAssertEqual(1, stored.count)
        XCTAssertEqual("RDV client", stored.first?.titre)
        XCTAssertEqual(1, api.createRdvCalls.count)
        let pending = try await db.syncOpDao.listAll()
        XCTAssertTrue(pending.allSatisfy { $0.entityType != "rdv" })
    }

    func testSyncNow_roomToAgendaUpsertsRdvEventAndStoresCalendarEventId() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
        var rdv = RdvEntity()
        rdv.serverId = "rdv-9"
        rdv.titre = "RDV serveur"
        rdv.debutMs = 5_000
        rdv.finMs = 6_000
        rdv.updatedAt = 1
        let localId = try await db.rdvDao.upsert(rdv)
        let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]

        _ = try await engine.syncNow(bindings: bindings, bridge: bridge)

        let updated = try await db.rdvDao.getByLocalId(localId)
        XCTAssertNotNil(updated?.calendarEventId)
        let events = try bridge.listEvents(calendarId: calendarId)
        XCTAssertEqual(1, events.count)
        XCTAssertEqual("RDV serveur", events.first?.title)
    }

    // ---- Fix HIGH : roomToAgenda deletes orphan events ----

    func testSyncNow_roomToAgendaDeletesOrphanEventAfterRdvTombstone() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
        let eventId = bridge.seedEvent(
            calendarId: calendarId,
            snap: CalendarEventSnapshot(title: "RDV serveur", debutMs: 1_000, finMs: 2_000, serverId: "rdv-old")
        )
        var rdv = RdvEntity()
        rdv.serverId = "rdv-old"
        rdv.titre = "RDV serveur"
        rdv.calendarEventId = eventId
        rdv.updatedAt = 1
        _ = try await db.rdvDao.upsert(rdv)
        let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
        api.pullResult = .ok(
            SyncPullResponse(
                serverTime: "2026-07-22T12:00:00Z",
                tombstones: [TombstoneDto(entityType: "rdv", id: "rdv-old", supprimeLe: "2026-07-22T12:00:00Z")]
            )
        )

        _ = try await engine.syncNow(bindings: bindings, bridge: bridge)

        let stored = try await db.rdvDao.getByServerId("rdv-old")
        XCTAssertNil(stored)
        XCTAssertTrue(bridge.deletedEventIds.contains(eventId))
        XCTAssertTrue(try bridge.listEvents(calendarId: calendarId).isEmpty)
    }

    func testSyncNow_roomToAgendaDeletesOrphanEventAfterReservationBecomesInactive() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A")
        let eventId = bridge.seedEvent(
            calendarId: calendarId,
            snap: CalendarEventSnapshot(title: "Réunion", debutMs: 1_000, finMs: 2_000, serverId: "resa-1")
        )
        var reservation = ReservationEntity()
        reservation.serverId = "resa-1"
        reservation.cibleType = "salle"
        reservation.cibleId = "s1"
        reservation.motif = "Réunion"
        reservation.calendarEventId = eventId
        reservation.updatedAt = 1
        reservation.statut = "active"
        _ = try await db.reservationDao.upsert(reservation)
        let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)]
        api.pullResult = .ok(
            SyncPullResponse(
                serverTime: "2026-07-22T12:00:00Z",
                reservations: [
                    ReservationDto(
                        id: "resa-1",
                        cible: CibleRessourceDto(type: "salle", id: "s1"),
                        debut: "2026-07-22T09:00:00Z",
                        fin: "2026-07-22T10:00:00Z",
                        statut: "annulee",
                        creeLe: "2026-07-20T10:00:00Z",
                        misAJourLe: "2026-07-22T11:00:00Z"
                    ),
                ]
            )
        )

        _ = try await engine.syncNow(bindings: bindings, bridge: bridge)

        let stored = try await db.reservationDao.getByServerId("resa-1")
        XCTAssertNil(stored)
        XCTAssertTrue(bridge.deletedEventIds.contains(eventId))
    }

    func testSyncNow_roomToAgendaKeepsPendingPushEventEvenWithoutServerId() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
        let eventId = bridge.seedEvent(
            calendarId: calendarId,
            snap: CalendarEventSnapshot(title: "Nouveau RDV", debutMs: 1_000, finMs: 2_000)
        )
        let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
        api.createRdvResult = .err(code: -1, message: "Réseau indisponible")

        _ = try await engine.syncNow(bindings: bindings, bridge: bridge)

        let stored = try await db.rdvDao.listAll()
        XCTAssertEqual(1, stored.count)
        XCTAssertNil(stored.first?.serverId)
        XCTAssertTrue(bridge.deletedEventIds.isEmpty)
        XCTAssertTrue(try bridge.listEvents(calendarId: calendarId).contains { $0.eventId == eventId })
    }

    // ---- Fix MEDIUM : agendaToRoom detects calendar-side deletions ----

    func testAgendaToRoom_detectsDeletedRdvEventWithServerIdEnqueuesDeleteOp() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
        var rdv = RdvEntity()
        rdv.serverId = "rdv-5"
        rdv.titre = "RDV supprimé"
        rdv.calendarEventId = 99
        rdv.updatedAt = 1
        _ = try await db.rdvDao.upsert(rdv)
        let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
        let coordinator = AgendaSyncCoordinator(db: db)

        try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge)

        let ops = try await db.syncOpDao.listAll().filter { $0.entityType == "rdv" }
        XCTAssertEqual(1, ops.count)
        XCTAssertEqual("delete", ops[0].op)
        XCTAssertEqual("rdv-5", ops[0].serverId)
        let stored = try await db.rdvDao.getByServerId("rdv-5")
        XCTAssertNil(stored)
    }

    func testAgendaToRoom_detectsDeletedRdvEventWithoutServerIdRemovesLocalEntityDirectly() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
        var rdv = RdvEntity()
        rdv.titre = "RDV jamais poussé"
        rdv.calendarEventId = 77
        rdv.updatedAt = 1
        let localId = try await db.rdvDao.upsert(rdv)
        let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]
        let coordinator = AgendaSyncCoordinator(db: db)

        try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge)

        let stored = try await db.rdvDao.getByLocalId(localId)
        XCTAssertNil(stored)
        let ops = try await db.syncOpDao.listAll()
        XCTAssertTrue(ops.allSatisfy { $0.entityType != "rdv" })
    }

    func testAgendaToRoom_detectsDeletedReservationEventWithServerIdEnqueuesDeleteOp() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A")
        var reservation = ReservationEntity()
        reservation.serverId = "resa-9"
        reservation.cibleType = "salle"
        reservation.cibleId = "s1"
        reservation.motif = "Réunion supprimée"
        reservation.calendarEventId = 55
        reservation.updatedAt = 1
        _ = try await db.reservationDao.upsert(reservation)
        let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)]
        let coordinator = AgendaSyncCoordinator(db: db)

        try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge)

        let ops = try await db.syncOpDao.listAll().filter { $0.entityType == "reservation" }
        XCTAssertEqual(1, ops.count)
        XCTAssertEqual("delete", ops[0].op)
        XCTAssertEqual("resa-9", ops[0].serverId)
        let stored = try await db.reservationDao.getByServerId("resa-9")
        XCTAssertNil(stored)
    }

    func testSyncNow_pushesDeleteOpFromDeletedReservationEventUsingPayloadCibleFallback() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A")
        var reservation = ReservationEntity()
        reservation.serverId = "resa-9"
        reservation.cibleType = "salle"
        reservation.cibleId = "s1"
        reservation.motif = "Réunion supprimée"
        reservation.calendarEventId = 55
        reservation.updatedAt = 1
        _ = try await db.reservationDao.upsert(reservation)
        let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)]

        // agendaToRoom (via syncNow) deletes the local entity; pushOps must then
        // resolve the target from the op payload.
        _ = try await engine.syncNow(bindings: bindings, bridge: bridge)

        XCTAssertEqual(1, api.deleteReservationCalls.count)
        XCTAssertEqual("salle", api.deleteReservationCalls[0].0)
        XCTAssertEqual("s1", api.deleteReservationCalls[0].1)
        XCTAssertEqual("resa-9", api.deleteReservationCalls[0].2)
    }

    func testAgendaToRoom_conflictPendingReservationNotDeletedWhenEventMissing() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Salle A")
        var reservation = ReservationEntity()
        reservation.serverId = "resa-conflict"
        reservation.cibleType = "salle"
        reservation.cibleId = "s1"
        reservation.motif = "En conflit"
        reservation.calendarEventId = 12
        reservation.updatedAt = 1
        reservation.conflictPending = true
        _ = try await db.reservationDao.upsert(reservation)
        let bindings = [CalendarBinding(kind: "salle", serverResourceId: "s1", displayName: "Salle A", androidCalendarId: calendarId)]
        let coordinator = AgendaSyncCoordinator(db: db)

        try await coordinator.agendaToRoom(bindings: bindings, bridge: bridge)

        let stored = try await db.reservationDao.getByServerId("resa-conflict")
        XCTAssertNotNil(stored)
        let ops = try await db.syncOpDao.listAll()
        XCTAssertTrue(ops.allSatisfy { $0.entityType != "reservation" })
    }

    // ---- Fix MEDIUM : localAheadCount with orphan events ----

    func testLocalAheadCount_withoutBridgeCountsDirtyAndPendingOpsOnly() async throws {
        var rdv = RdvEntity()
        rdv.titre = "Dirty"
        rdv.dirtyLocal = true
        _ = try await db.rdvDao.upsert(rdv)
        var op = SyncOpEntity(entityType: "reservation", op: "create")
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        let count = try await engine.localAheadCount()

        XCTAssertEqual(2, count)
    }

    func testLocalAheadCount_withBridgeIncludesOrphanTaggedEvents() async throws {
        let bridge = FakeCalendarBridge()
        let calendarId = try bridge.ensureLocalCalendar(displayName: "Card2vcf — Mes RDV")
        bridge.seedEvent(
            calendarId: calendarId,
            snap: CalendarEventSnapshot(title: "Orphelin", debutMs: 1_000, finMs: 2_000, serverId: "rdv-orphan")
        )
        let bindings = [CalendarBinding(kind: "rdv", displayName: "Mes RDV", androidCalendarId: calendarId)]

        let count = try await engine.localAheadCount(bindings: bindings, bridge: bridge)

        XCTAssertEqual(1, count)
    }

    // ---- Fix LOW : delete reservation without serverId ----

    func testPushReservationDeleteWithoutServerIdDoesNotDeleteSyncOp() async throws {
        var reservation = ReservationEntity()
        reservation.cibleType = "salle"
        reservation.cibleId = "s1"
        reservation.motif = "x"
        let localId = try await db.reservationDao.upsert(reservation)
        var op = SyncOpEntity(entityType: "reservation", op: "delete")
        op.localId = localId
        op.createdAt = 1
        _ = try await db.syncOpDao.insert(op)

        _ = try await engine.syncNow()

        let pending = try await db.syncOpDao.listAll()
        XCTAssertEqual(1, pending.count)
    }
}