feat: implement Phase 1 MVP of Implausibly
Working Android dashboard app for self-hosted Plausible Analytics CE. Connects to Plausible API v2 (POST /api/v2/query), displays top stats, visitor chart, top sources, and top pages with date range selection. Architecture: Kotlin + Jetpack Compose + Material 3 + Hilt + Ktor + SQLDelight + EncryptedSharedPreferences. Single :app module, four-layer unidirectional data flow (UI → ViewModel → Repository → Data). Includes: instance management, site list, caching with TTL per date range, encrypted API key storage, custom Canvas visitor chart, pull-to-refresh, and unit tests for API, cache, repository, and domain model layers. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
commit
aa66172d58
69 changed files with 4778 additions and 0 deletions
|
|
@ -0,0 +1,143 @@
|
|||
// SPDX-License-Identifier: GPL-3.0-only
|
||||
package no.naiv.implausibly.data.remote
|
||||
|
||||
import io.ktor.client.HttpClient
|
||||
import io.ktor.client.engine.mock.MockEngine
|
||||
import io.ktor.client.engine.mock.respond
|
||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||
import io.ktor.http.ContentType
|
||||
import io.ktor.http.HttpHeaders
|
||||
import io.ktor.http.HttpStatusCode
|
||||
import io.ktor.http.headersOf
|
||||
import io.ktor.serialization.kotlinx.json.json
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.serialization.json.Json
|
||||
import no.naiv.implausibly.data.remote.dto.Pagination
|
||||
import no.naiv.implausibly.data.remote.dto.QueryRequest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Test
|
||||
|
||||
class PlausibleApiImplTest {
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true; isLenient = true }
|
||||
|
||||
private fun createClient(responseBody: String, statusCode: HttpStatusCode = HttpStatusCode.OK): Pair<HttpClient, MockEngine> {
|
||||
val engine = MockEngine { _ ->
|
||||
respond(
|
||||
content = responseBody,
|
||||
status = statusCode,
|
||||
headers = headersOf(HttpHeaders.ContentType, ContentType.Application.Json.toString())
|
||||
)
|
||||
}
|
||||
val client = HttpClient(engine) {
|
||||
install(ContentNegotiation) { json(json) }
|
||||
}
|
||||
return client to engine
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query sends correct request and parses response`() = runTest {
|
||||
val responseJson = """
|
||||
{
|
||||
"results": [
|
||||
{
|
||||
"dimensions": [],
|
||||
"metrics": [1234, 3000, 45.2, 52.1]
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val (client, engine) = createClient(responseJson)
|
||||
val api = PlausibleApiImpl(client)
|
||||
|
||||
val request = QueryRequest(
|
||||
siteId = "example.com",
|
||||
metrics = listOf("visitors", "pageviews", "bounce_rate", "visit_duration"),
|
||||
dateRange = "30d"
|
||||
)
|
||||
|
||||
val response = api.query("https://plausible.example.com", "test-key", request)
|
||||
|
||||
assertEquals(1, response.results.size)
|
||||
assertEquals(4, response.results[0].metrics.size)
|
||||
|
||||
// Verify the request was sent to the correct URL
|
||||
val requestHistory = engine.requestHistory
|
||||
assertEquals(1, requestHistory.size)
|
||||
assertEquals("https://plausible.example.com/api/v2/query", requestHistory[0].url.toString())
|
||||
|
||||
// Verify auth header
|
||||
assertEquals("Bearer test-key", requestHistory[0].headers[HttpHeaders.Authorization])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query without api key omits auth header`() = runTest {
|
||||
val responseJson = """{"results": []}"""
|
||||
val (client, engine) = createClient(responseJson)
|
||||
val api = PlausibleApiImpl(client)
|
||||
|
||||
val request = QueryRequest(
|
||||
siteId = "example.com",
|
||||
metrics = listOf("visitors"),
|
||||
dateRange = "7d"
|
||||
)
|
||||
|
||||
api.query("https://plausible.example.com", null, request)
|
||||
|
||||
val requestHistory = engine.requestHistory
|
||||
assertEquals(null, requestHistory[0].headers[HttpHeaders.Authorization])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query strips trailing slash from base URL`() = runTest {
|
||||
val responseJson = """{"results": []}"""
|
||||
val (client, engine) = createClient(responseJson)
|
||||
val api = PlausibleApiImpl(client)
|
||||
|
||||
val request = QueryRequest(
|
||||
siteId = "example.com",
|
||||
metrics = listOf("visitors"),
|
||||
dateRange = "7d"
|
||||
)
|
||||
|
||||
api.query("https://plausible.example.com/", null, request)
|
||||
|
||||
assertEquals(
|
||||
"https://plausible.example.com/api/v2/query",
|
||||
engine.requestHistory[0].url.toString()
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `query parses dimension response`() = runTest {
|
||||
val responseJson = """
|
||||
{
|
||||
"results": [
|
||||
{"dimensions": ["Google"], "metrics": [842]},
|
||||
{"dimensions": ["Direct"], "metrics": [512]},
|
||||
{"dimensions": ["twitter.com"], "metrics": [201]}
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val (client, _) = createClient(responseJson)
|
||||
val api = PlausibleApiImpl(client)
|
||||
|
||||
val request = QueryRequest(
|
||||
siteId = "example.com",
|
||||
metrics = listOf("visitors"),
|
||||
dateRange = "30d",
|
||||
dimensions = listOf("visit:source"),
|
||||
orderBy = listOf(listOf("visitors", "desc")),
|
||||
pagination = Pagination(limit = 10)
|
||||
)
|
||||
|
||||
val response = api.query("https://plausible.example.com", "key", request)
|
||||
|
||||
assertEquals(3, response.results.size)
|
||||
assertNotNull(response.results[0].dimensions)
|
||||
assertEquals(1, response.results[0].dimensions.size)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue