CalendarBridgeTest.kt 205 lignes · 7501 octets
package fr.ebii.card2vcf.sync

import android.content.ContentProvider
import android.content.ContentResolver
import android.content.ContentUris
import android.content.ContentValues
import android.content.Context
import android.content.UriMatcher
import android.content.pm.ProviderInfo
import android.database.Cursor
import android.database.MatrixCursor
import android.net.Uri
import android.provider.CalendarContract
import androidx.test.core.app.ApplicationProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config

/**
 * Couvre [CalendarBridge] au-delà des fonctions pures déjà testées dans
 * `CalendarServerIdCodecTest` : `ensureLocalCalendar`/`listEvents`/`upsertEvent`/`deleteEvent`
 * via un `ContentProvider` en mémoire ([FakeCalendarProvider]), le vrai fournisseur Agenda
 * n'étant pas embarqué dans `android-all` sous Robolectric.
 */
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [31])
class CalendarBridgeTest {
    private lateinit var bridge: CalendarBridge

    @Before
    fun setUp() {
        val providerInfo = ProviderInfo().apply { authority = CalendarContract.AUTHORITY }
        Robolectric.buildContentProvider(FakeCalendarProvider::class.java).create(providerInfo)

        val resolver = ApplicationProvider.getApplicationContext<Context>().contentResolver
        bridge = CalendarBridge(resolver)
    }

    @Test
    fun ensureLocalCalendarCreatesThenReusesSameCalendar() {
        val firstId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
        val secondId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")

        assertEquals(firstId, secondId)
    }

    @Test
    fun ensureLocalCalendarCreatesDistinctCalendarsForDistinctNames() {
        val rdvId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
        val salleId = bridge.ensureLocalCalendar("Card2vcf — Salle A")

        assertTrue(rdvId != salleId)
    }

    @Test
    fun listEventsOnEmptyCalendarIsEmpty() {
        val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")

        assertTrue(bridge.listEvents(calendarId).isEmpty())
    }

    @Test
    fun upsertEventInsertsThenListEventsReturnsIt() {
        val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
        val snapshot = CalendarEventSnapshot(
            title = "RDV client",
            debutMs = 1_000L,
            finMs = 2_000L,
            serverId = "rdv-42",
            descriptionBody = "notes libres",
        )

        val eventId = bridge.upsertEvent(calendarId, snapshot)

        assertEquals(listOf(snapshot.copy(eventId = eventId)), bridge.listEvents(calendarId))
    }

    @Test
    fun upsertEventWithExistingEventIdUpdatesInPlace() {
        val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
        val eventId = bridge.upsertEvent(
            calendarId,
            CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L, serverId = "rdv-42"),
        )

        val updatedId = bridge.upsertEvent(
            calendarId,
            CalendarEventSnapshot(
                title = "RDV client (déplacé)",
                debutMs = 3_000L,
                finMs = 4_000L,
                eventId = eventId,
                serverId = "rdv-42",
            ),
        )

        assertEquals(eventId, updatedId)
        val events = bridge.listEvents(calendarId)
        assertEquals(1, events.size)
        assertEquals("RDV client (déplacé)", events.single().title)
        assertEquals(3_000L, events.single().debutMs)
    }

    @Test
    fun deleteEventRemovesItFromListEvents() {
        val calendarId = bridge.ensureLocalCalendar("Card2vcf — Mes RDV")
        val eventId = bridge.upsertEvent(
            calendarId,
            CalendarEventSnapshot(title = "RDV client", debutMs = 1_000L, finMs = 2_000L),
        )

        bridge.deleteEvent(eventId)

        assertTrue(bridge.listEvents(calendarId).isEmpty())
    }
}

/**
 * Fournisseur Agenda minimal en mémoire, pour tester [CalendarBridge] sous Robolectric
 * (le vrai `CalendarProvider2` de l'app Agenda n'est pas embarqué dans `android-all`).
 * Ne supporte que les projections/sélections utilisées par [CalendarBridge].
 */
class FakeCalendarProvider : ContentProvider() {
    private val calendars = mutableMapOf<Long, ContentValues>()
    private val events = mutableMapOf<Long, ContentValues>()
    private var nextId = 1L

    override fun onCreate(): Boolean = true

    override fun getType(uri: Uri): String? = null

    override fun insert(uri: Uri, values: ContentValues?): Uri {
        val id = nextId++
        val row = ContentValues(values).apply { put("_id", id) }
        when (matcher.match(uri)) {
            CALENDARS -> calendars[id] = row
            EVENTS -> events[id] = row
            else -> error("Uri non supportée par FakeCalendarProvider : $uri")
        }
        return ContentUris.withAppendedId(baseUriFor(matcher.match(uri)), id)
    }

    override fun query(
        uri: Uri,
        projection: Array<out String>?,
        selection: String?,
        selectionArgs: Array<out String>?,
        sortOrder: String?,
    ): Cursor {
        val table = when (matcher.match(uri)) {
            CALENDARS -> calendars
            EVENTS -> events
            else -> error("Uri non supportée par FakeCalendarProvider : $uri")
        }
        val columns = projection ?: arrayOf("_id")
        val cursor = MatrixCursor(columns)
        table.toSortedMap().values
            .filter { matchesSelection(it, selection, selectionArgs) }
            .forEach { row -> cursor.addRow(columns.map { column -> row.get(column) }) }
        return cursor
    }

    override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int {
        val id = ContentUris.parseId(uri)
        val row = events[id] ?: return 0
        values?.let { row.putAll(it) }
        return 1
    }

    override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int {
        val id = ContentUris.parseId(uri)
        return if (events.remove(id) != null) 1 else 0
    }

    private fun baseUriFor(matchCode: Int): Uri = when (matchCode) {
        CALENDARS -> Uri.parse("content://${CalendarContract.AUTHORITY}/calendars")
        EVENTS -> Uri.parse("content://${CalendarContract.AUTHORITY}/events")
        else -> error("Code de correspondance inconnu : $matchCode")
    }

    private fun matchesSelection(row: ContentValues, selection: String?, args: Array<out String>?): Boolean {
        if (selection.isNullOrBlank()) return true
        return selection.split(" AND ").withIndex().all { (index, condition) ->
            val column = condition.substringBefore(" = ?").trim()
            row.get(column)?.toString() == args?.getOrNull(index)
        }
    }

    private companion object {
        const val CALENDARS = 1
        const val EVENTS = 2

        val matcher = UriMatcher(UriMatcher.NO_MATCH).apply {
            addURI(CalendarContract.AUTHORITY, "calendars", CALENDARS)
            addURI(CalendarContract.AUTHORITY, "calendars/#", CALENDARS)
            addURI(CalendarContract.AUTHORITY, "events", EVENTS)
            addURI(CalendarContract.AUTHORITY, "events/#", EVENTS)
        }
    }
}