M12e: contract pass (pair invite kind, admin name in invite links, sync failed flag, rejection actor, device version name, template id, update check result, untouched accounts, D-027 re-stamp, PushEvent in :core:model, snackbar hosts)
Server: E-3 invite_kind; E-12 link ?from=<admin name>, E-4 passes it into kvitt://invite; X-Kvitt-App-Version-Name stored in devices.app_version_name (migration 0003, schema 3); OpenAPI and examples follow. Phone: :core:model, :core:fakes, :data, features and :app updated together; goldens re-recorded for S-20 (version name) and new S-1/S-22 states. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -26,10 +26,11 @@ data class AcceptInviteRequest(
|
||||
@SerialName("display_name") val displayName: String? = null,
|
||||
)
|
||||
|
||||
/** E-3 response. */
|
||||
/** E-3 response. `invite_kind`: the accepted invite's kind (`admin_setup`, `partner`, `device`). */
|
||||
@Serializable
|
||||
data class AcceptInviteResponse(
|
||||
val token: String,
|
||||
@SerialName("invite_kind") val inviteKind: String,
|
||||
val user: UserDto,
|
||||
val device: DeviceDto,
|
||||
val household: List<UserDto>,
|
||||
|
||||
@@ -32,13 +32,16 @@ data class ApiTarget(val baseUrl: String, val token: String?)
|
||||
interface ApiClientInfo {
|
||||
val appVersionCode: Int
|
||||
|
||||
/** The `versionName`, for `X-Kvitt-App-Version-Name` (schema 3, S-20). */
|
||||
val appVersionName: String
|
||||
|
||||
/** The device locale as a BCP 47 tag, for `Accept-Language`. */
|
||||
fun acceptLanguage(): String
|
||||
}
|
||||
|
||||
/**
|
||||
* The Ktor client of every endpoint the app calls (§14.3.4). Every request carries `X-Kvitt-Protocol: 1`,
|
||||
* `X-Kvitt-App-Version` and `Accept-Language`, and authenticated calls `Authorization: Bearer` (§14.3.1). The token is
|
||||
* `X-Kvitt-App-Version`, `X-Kvitt-App-Version-Name` and `Accept-Language`, and authenticated calls `Authorization: Bearer` (§14.3.1). The token is
|
||||
* only ever in that header, never in a URL or a log (§8.2). Errors map to the §14.3.2 codes; 401 and 426 are reported
|
||||
* to [events] so the session and the sync engine react in one place.
|
||||
*/
|
||||
@@ -179,6 +182,7 @@ class KvittApi(
|
||||
private fun HttpRequestBuilder.commonHeaders() {
|
||||
header(PROTOCOL_HEADER, PROTOCOL_VERSION)
|
||||
header(APP_VERSION_HEADER, info.appVersionCode.toString())
|
||||
versionNameHeader(info.appVersionName)?.let { header(APP_VERSION_NAME_HEADER, it) }
|
||||
header(HttpHeaders.AcceptLanguage, info.acceptLanguage())
|
||||
}
|
||||
|
||||
@@ -228,6 +232,12 @@ class KvittApi(
|
||||
const val PROTOCOL_HEADER = "X-Kvitt-Protocol"
|
||||
const val PROTOCOL_VERSION = "1"
|
||||
const val APP_VERSION_HEADER = "X-Kvitt-App-Version"
|
||||
const val APP_VERSION_NAME_HEADER = "X-Kvitt-App-Version-Name"
|
||||
private const val VERSION_NAME_MAX = 32
|
||||
|
||||
/** The header value, or null when [name] is blank, longer than 32 or not printable ASCII (the server would ignore it). */
|
||||
fun versionNameHeader(name: String): String? =
|
||||
name.trim().takeIf { it.length in 1..VERSION_NAME_MAX && it.all { c -> c in ' '..'~' } }
|
||||
const val SERVER_VERSION_HEADER = "X-Kvitt-Server-Version"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,16 +8,19 @@ import io.ktor.http.HttpHeaders
|
||||
class PictureAuthConfig {
|
||||
var target: suspend () -> ApiTarget? = { null }
|
||||
var appVersionCode: Int = 0
|
||||
var appVersionName: String = ""
|
||||
}
|
||||
|
||||
val PictureAuth: ClientPlugin<PictureAuthConfig> = createClientPlugin("KvittPictureAuth", ::PictureAuthConfig) {
|
||||
val target = pluginConfig.target
|
||||
val versionCode = pluginConfig.appVersionCode
|
||||
val versionName = KvittApi.versionNameHeader(pluginConfig.appVersionName)
|
||||
onRequest { request, _ ->
|
||||
val t = target() ?: return@onRequest
|
||||
if (!request.url.buildString().startsWith(t.baseUrl.trimEnd('/') + "/")) return@onRequest
|
||||
request.headers.append(KvittApi.PROTOCOL_HEADER, KvittApi.PROTOCOL_VERSION)
|
||||
request.headers.append(KvittApi.APP_VERSION_HEADER, versionCode.toString())
|
||||
versionName?.let { request.headers.append(KvittApi.APP_VERSION_NAME_HEADER, it) }
|
||||
t.token?.let { request.headers.append(HttpHeaders.Authorization, "Bearer $it") }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ data class DeviceDto(
|
||||
@SerialName("user_id") val userId: String,
|
||||
val name: String,
|
||||
@SerialName("app_version_code") val appVersionCode: Int?,
|
||||
@SerialName("app_version_name") val appVersionName: String?,
|
||||
@SerialName("created_at") val createdAt: String,
|
||||
@SerialName("last_seen_at") val lastSeenAt: String,
|
||||
@SerialName("revoked_at") val revokedAt: String?,
|
||||
|
||||
@@ -114,6 +114,13 @@ interface ExpenseDao {
|
||||
@Query("SELECT * FROM expenses WHERE pending = 1 ORDER BY local_updated_at, id LIMIT :limit")
|
||||
suspend fun pending(limit: Int): List<ExpenseEntity>
|
||||
|
||||
/** D-027: pending rows stamped later than [now] (by a clock that ran ahead) take [now]; a tombstone's `deleted_at` too. */
|
||||
@Query(
|
||||
"UPDATE expenses SET updated_at = :now, deleted_at = CASE WHEN deleted_at > :now THEN :now ELSE deleted_at END " +
|
||||
"WHERE pending = 1 AND updated_at > :now",
|
||||
)
|
||||
suspend fun restampFuturePending(now: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM expenses WHERE pending = 1")
|
||||
fun observePendingCount(): Flow<Int>
|
||||
|
||||
@@ -173,6 +180,13 @@ interface SettlementDao {
|
||||
@Query("SELECT * FROM settlements WHERE pending = 1 ORDER BY local_updated_at, id LIMIT :limit")
|
||||
suspend fun pending(limit: Int): List<SettlementEntity>
|
||||
|
||||
/** D-027, as for expenses. */
|
||||
@Query(
|
||||
"UPDATE settlements SET updated_at = :now, deleted_at = CASE WHEN deleted_at > :now THEN :now ELSE deleted_at END " +
|
||||
"WHERE pending = 1 AND updated_at > :now",
|
||||
)
|
||||
suspend fun restampFuturePending(now: String): Int
|
||||
|
||||
@Query("SELECT COUNT(*) FROM settlements WHERE pending = 1")
|
||||
fun observePendingCount(): Flow<Int>
|
||||
|
||||
|
||||
@@ -156,6 +156,7 @@ object DataModule {
|
||||
install(PictureAuth) {
|
||||
target = { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } }
|
||||
appVersionCode = build.versionCode
|
||||
appVersionName = build.versionName
|
||||
}
|
||||
}
|
||||
return Pictures.imageLoader(context, client) { base.value }
|
||||
@@ -180,6 +181,7 @@ object DataModule {
|
||||
target = { credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } },
|
||||
info = object : ApiClientInfo {
|
||||
override val appVersionCode: Int = build.versionCode
|
||||
override val appVersionName: String = build.versionName
|
||||
override fun acceptLanguage(): String = Locale.getDefault().toLanguageTag()
|
||||
},
|
||||
events = session,
|
||||
|
||||
@@ -11,6 +11,7 @@ import se.hagstedt.kvitt.data.api.PatchAccountRequest
|
||||
import se.hagstedt.kvitt.data.api.ShareBpDto
|
||||
import se.hagstedt.kvitt.data.connectivity.OnlineGate
|
||||
import se.hagstedt.kvitt.data.db.KvittDatabase
|
||||
import se.hagstedt.kvitt.data.db.SyncStateEntity
|
||||
import se.hagstedt.kvitt.data.sync.LocalRows
|
||||
import se.hagstedt.kvitt.data.sync.SyncEngine
|
||||
import se.hagstedt.kvitt.data.util.Clock
|
||||
@@ -46,8 +47,24 @@ class SyncSummaries(db: KvittDatabase, engine: SyncEngine) {
|
||||
rejectedCount = rejections.size,
|
||||
clockSkew = state?.clockSkew ?: false,
|
||||
running = running,
|
||||
lastAttemptFailed = lastAttemptFailed(state, running),
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
companion object {
|
||||
/**
|
||||
* The last finished cycle failed: it started (`last_attempt_at`) after the last success, or none succeeded yet,
|
||||
* and no cycle runs now. Every cycle stamps `last_attempt_at` when it starts and `last_success_at` when it ends
|
||||
* well, so a later attempt than success means it ended badly (network, 5xx, 401, 426, a failed pull or a
|
||||
* `clock_skew` stop). Both are fixed-form RFC 3339 instants (§14.3.3), compared as text.
|
||||
*/
|
||||
fun lastAttemptFailed(state: SyncStateEntity?, running: Boolean): Boolean {
|
||||
val attempt = state?.lastAttemptAt ?: return false
|
||||
if (running) return false
|
||||
val success = state.lastSuccessAt ?: return true
|
||||
return attempt > success
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun UpdateState.info(): UpdateInfo? = when (this) {
|
||||
|
||||
@@ -20,10 +20,11 @@ import se.hagstedt.kvitt.data.sync.SyncTriggers
|
||||
import se.hagstedt.kvitt.data.sync.toEntity
|
||||
import se.hagstedt.kvitt.data.util.Clock
|
||||
import se.hagstedt.kvitt.model.DeviceId
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.Pairing
|
||||
import se.hagstedt.kvitt.model.SessionRepository
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
import se.hagstedt.kvitt.model.User
|
||||
import java.net.URI
|
||||
|
||||
/** Pairing (S-1, AC-2.4 to AC-2.9), revocation (AC-3.3) and re-pairing (§14.4.8). */
|
||||
@@ -57,7 +58,7 @@ class SessionRepositoryImpl(
|
||||
}
|
||||
}.filterNotNull().distinctUntilChanged()
|
||||
|
||||
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<User> {
|
||||
override suspend fun pair(serverUrl: String, code: String, displayName: String?, deviceName: String): OnlineResult<Pairing> {
|
||||
val baseUrl = normaliseServerUrl(serverUrl, debugBuild) ?: return OnlineResult.Failed(ErrorCodes.INVALID_REQUEST, "server_url")
|
||||
val normalised = normaliseInviteCode(code) ?: return OnlineResult.Failed(INVITE_INVALID, "code")
|
||||
val request = AcceptInviteRequest(normalised, deviceName.trim(), displayName?.trim()?.takeIf { it.isNotEmpty() })
|
||||
@@ -70,7 +71,7 @@ class SessionRepositoryImpl(
|
||||
// §14.4.8: the same user keeps its data and pushes everything again; AC-2.9: then a full sync.
|
||||
if (sameUser) engine.prepareRepush()
|
||||
triggers.onAppStart()
|
||||
response.user.toEntity(clock.nowMillis()).toModel()
|
||||
Pairing(response.user.toEntity(clock.nowMillis()).toModel(), inviteKindOf(response.inviteKind))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,6 +85,9 @@ class SessionRepositoryImpl(
|
||||
private const val ALPHABET = "0123456789ABCDEFGHJKMNPQRSTVWXYZ"
|
||||
private const val CODE_LENGTH = 12
|
||||
|
||||
/** E-3 `invite_kind`; a value this app does not know is read as `device`, which leads straight to Home. */
|
||||
fun inviteKindOf(wire: String): InviteKind = InviteKind.entries.firstOrNull { it.name.lowercase() == wire } ?: InviteKind.DEVICE
|
||||
|
||||
/** §8.3: uppercase; drop `-` and spaces; O -> 0, I and L -> 1; then exactly 12 alphabet characters. */
|
||||
fun normaliseInviteCode(code: String): String? {
|
||||
val normalised = code.uppercase().filter { it != '-' && !it.isWhitespace() }
|
||||
|
||||
@@ -48,6 +48,7 @@ import se.hagstedt.kvitt.model.InviteId
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
import se.hagstedt.kvitt.model.InviteRepository
|
||||
import se.hagstedt.kvitt.model.LedgerRow
|
||||
import se.hagstedt.kvitt.model.NewRecurringTemplate
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.ProfileRepository
|
||||
import se.hagstedt.kvitt.model.RecurringRepository
|
||||
@@ -144,8 +145,9 @@ class RecurringRepositoryImpl(
|
||||
s.liveTemplates.sortedWith(compareBy({ it.row.createdAt }, { it.row.id })).map { it.toModel() }
|
||||
}.distinctUntilChanged()
|
||||
|
||||
override suspend fun create(t: RecurringTemplate): OnlineResult<TemplateId> {
|
||||
val body = t.toWire(local.snapshot.first().household.map { it.id }) ?: return t.missing()
|
||||
/** E-28 has no id in its body: the server mints the template id, which comes back here. */
|
||||
override suspend fun create(t: NewRecurringTemplate): OnlineResult<TemplateId> {
|
||||
val body = t.toWire(local.snapshot.first().household.map { it.id }) ?: return t.draft.missing()
|
||||
return gate.run({ api.createTemplate(body) }) {
|
||||
rows.writeTemplateIfNewer(it)
|
||||
TemplateId(it.id)
|
||||
@@ -153,7 +155,8 @@ class RecurringRepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun update(t: RecurringTemplate): OnlineResult<Unit> {
|
||||
val body = t.toWire(local.snapshot.first().household.map { it.id }) ?: return t.missing()
|
||||
val fields = NewRecurringTemplate(t.draft, t.dayOfMonth, t.startPeriod, t.endPeriod, t.paused)
|
||||
val body = fields.toWire(local.snapshot.first().household.map { it.id }) ?: return t.draft.missing()
|
||||
val patch = PatchTemplateRequest(
|
||||
accountId = body.accountId,
|
||||
categoryId = body.categoryId,
|
||||
@@ -173,16 +176,16 @@ class RecurringRepositoryImpl(
|
||||
|
||||
override suspend fun delete(id: TemplateId): OnlineResult<Unit> = gate.run({ api.deleteTemplate(id.value) }) { rows.writeTemplateIfNewer(it) }
|
||||
|
||||
private fun RecurringTemplate.missing(): OnlineResult.Failed = when {
|
||||
draft.accountId == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "account_id")
|
||||
draft.categoryId == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "category_id")
|
||||
draft.amountOre == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "amount_ore")
|
||||
private fun ExpenseDraft.missing(): OnlineResult.Failed = when {
|
||||
accountId == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "account_id")
|
||||
categoryId == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "category_id")
|
||||
amountOre == null -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "amount_ore")
|
||||
else -> OnlineResult.Failed(ErrorCodes.VALIDATION_FAILED, "splits")
|
||||
}
|
||||
}
|
||||
|
||||
/** E-28 body from the model; splits per §9.1: none for default, two bp for ratio and full, two öre for exact. */
|
||||
private fun RecurringTemplate.toWire(household: List<UserId>): CreateTemplateRequest? {
|
||||
private fun NewRecurringTemplate.toWire(household: List<UserId>): CreateTemplateRequest? {
|
||||
val accountId = draft.accountId ?: return null
|
||||
val categoryId = draft.categoryId ?: return null
|
||||
val amount = draft.amountOre ?: return null
|
||||
@@ -254,7 +257,7 @@ class DeviceRepositoryImpl(private val local: LocalData, private val api: KvittA
|
||||
|
||||
private fun DeviceDto.toModel(s: Snapshot): Device? {
|
||||
val user = s.user(userId) ?: return null
|
||||
return Device(DeviceId(id), user, name, createdAt, lastSeenAt, appVersionCode, isCurrent, revokedAt != null)
|
||||
return Device(DeviceId(id), user, name, createdAt, lastSeenAt, appVersionCode, appVersionName, isCurrent, revokedAt != null)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -294,7 +297,7 @@ class SyncRepositoryImpl(
|
||||
SyncStatusState(
|
||||
summary = summary,
|
||||
pending = rows.sortedWith { a, b -> ledgerOrder.compare(a.first, b.first) }.mapNotNull { it.second },
|
||||
rejections = rejections.mapNotNull { it.toModel() },
|
||||
rejections = rejections.mapNotNull { it.toModel(s) },
|
||||
)
|
||||
}.distinctUntilChanged()
|
||||
|
||||
@@ -308,21 +311,25 @@ class SyncRepositoryImpl(
|
||||
|
||||
/**
|
||||
* A rejection for S-16. `summary` is the rejected entity's own text (expense description; settlement note, or its
|
||||
* amount in öre when the note is empty); the screen adds the explanation for the reason (§14.4.6).
|
||||
* amount in öre when the note is empty); the screen adds the explanation for the reason (§14.4.6). `actor` is the
|
||||
* `updated_by_user_id` of the server's `current` row stored with the rejection (§14.4.3), resolved to a household user.
|
||||
*/
|
||||
private fun SyncRejectionEntity.toModel(): Rejection? {
|
||||
internal fun SyncRejectionEntity.toModel(s: Snapshot): Rejection? {
|
||||
val type = EntityType.entries.firstOrNull { it.name.lowercase() == entityType } ?: return null
|
||||
val reason = SyncRejectionReason.entries.firstOrNull { it.name.lowercase() == reason } ?: return null
|
||||
val payload = try {
|
||||
Json.parseToJsonElement(rejectedPayload) as? JsonObject
|
||||
} catch (e: kotlinx.serialization.SerializationException) {
|
||||
null
|
||||
}
|
||||
val payload = jsonObjectOrNull(rejectedPayload)
|
||||
val actor = serverCurrent?.let(::jsonObjectOrNull)?.get("updated_by_user_id")?.jsonPrimitive?.contentOrNull?.let(s::user)
|
||||
val text = when (type) {
|
||||
EntityType.EXPENSE -> payload?.get("description")?.jsonPrimitive?.contentOrNull
|
||||
EntityType.SETTLEMENT -> payload?.get("note")?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: payload?.get("amount_ore")?.jsonPrimitive?.contentOrNull
|
||||
}
|
||||
return Rejection(mutationId, type, entityId, reason, text.orEmpty(), rejectedAt)
|
||||
return Rejection(mutationId, type, entityId, reason, text.orEmpty(), rejectedAt, actor)
|
||||
}
|
||||
|
||||
private fun jsonObjectOrNull(text: String): JsonObject? = try {
|
||||
Json.parseToJsonElement(text) as? JsonObject
|
||||
} catch (e: kotlinx.serialization.SerializationException) {
|
||||
null
|
||||
}
|
||||
|
||||
/** S-22 (FR-16 versions, the paired server and the server's version header). */
|
||||
|
||||
+11
-1
@@ -26,6 +26,7 @@ import se.hagstedt.kvitt.model.SettleState
|
||||
import se.hagstedt.kvitt.model.SettlementDetailState
|
||||
import se.hagstedt.kvitt.model.SettlementId
|
||||
import se.hagstedt.kvitt.model.SettlementRepository
|
||||
import se.hagstedt.kvitt.model.UntouchedAccount
|
||||
import se.hagstedt.kvitt.model.UserId
|
||||
import se.hagstedt.kvitt.model.money.AccountPosition
|
||||
import se.hagstedt.kvitt.model.money.Allocation
|
||||
@@ -171,5 +172,14 @@ internal fun Snapshot.settleState(request: SettleRequest): SettleState {
|
||||
val valid = amount != null && validateSettlement(
|
||||
amount, payer.id.value, recipient.id.value, household.map { it.id.value }, lines.map { Allocation(it.account.id.value, it.amountOre) },
|
||||
) == null
|
||||
return SettleState(payer, recipient, amount, lines, (amount ?: 0L) - allocated, valid, request.date, request.note)
|
||||
// §9.4 info line (D-074): accounts the payer is owed in that the rows leave out; none for a per-account settle-up.
|
||||
val untouched = if (fixed != null) {
|
||||
emptyList()
|
||||
} else {
|
||||
accounts.mapNotNull { account ->
|
||||
val net = balances.net(account.id.value, request.payer)
|
||||
UntouchedAccount(account, net).takeIf { net > 0 && lines.none { it.account.id == account.id } }
|
||||
}
|
||||
}
|
||||
return SettleState(payer, recipient, amount, lines, (amount ?: 0L) - allocated, valid, request.date, request.note, untouched)
|
||||
}
|
||||
|
||||
@@ -85,6 +85,7 @@ class SyncEngine(
|
||||
|
||||
private suspend fun cycle(): SyncOutcome {
|
||||
updateState { it.copy(lastAttemptAt = clock.nowTimestamp()) }
|
||||
if (state().clockSkew) restampAfterClockSkew()
|
||||
var restarts = 0
|
||||
while (true) {
|
||||
val push = pushAll()
|
||||
@@ -218,6 +219,20 @@ class SyncEngine(
|
||||
* (§14.4.4 steps 1 and 12), so the row gets a new mutation id; otherwise it could never sync after the clock is
|
||||
* fixed (proposed decision in the M5 report). A row edited during the request already has a new one.
|
||||
*/
|
||||
/**
|
||||
* D-027 (§14.4.5 step 3): on the first attempt after a `clock_skew` rejection, every pending row stamped later than
|
||||
* the phone's clock now is re-stamped to now, so a corrected clock heals it. Rows stamped by a clock that is still
|
||||
* ahead are not later than now and stay as they are (they are refused again until the clock is fixed). The mutation
|
||||
* id was already renewed when the rejection arrived.
|
||||
*/
|
||||
private suspend fun restampAfterClockSkew() {
|
||||
val now = clock.nowTimestamp()
|
||||
db.withWriteTransaction {
|
||||
db.expenses().restampFuturePending(now)
|
||||
db.settlements().restampFuturePending(now)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun renewMutationId(pending: Pending) {
|
||||
val sent = pending.mutation.mutationId
|
||||
when (pending.entityType) {
|
||||
|
||||
@@ -20,6 +20,7 @@ import se.hagstedt.kvitt.data.api.ErrorCodes
|
||||
import se.hagstedt.kvitt.data.api.KvittApi
|
||||
import se.hagstedt.kvitt.data.repo.PhonePrefs
|
||||
import se.hagstedt.kvitt.data.util.Clock
|
||||
import se.hagstedt.kvitt.model.UpdateCheck
|
||||
import se.hagstedt.kvitt.model.UpdateInfo
|
||||
import se.hagstedt.kvitt.model.UpdateRepository
|
||||
import se.hagstedt.kvitt.model.UpdateState
|
||||
@@ -75,12 +76,17 @@ class UpdateRepositoryImpl(
|
||||
private var latest: ApkLatestDto? = null
|
||||
private var lastCheckAt: Long? = null
|
||||
|
||||
override suspend fun check() {
|
||||
if (debugBuild) return
|
||||
/**
|
||||
* Within 6 hours of the last check that reached the server the stored answer is reported without a request. A 404
|
||||
* (nothing published) is "up to date"; another error or no answer is [UpdateCheck.Failed] and leaves [state]
|
||||
* unchanged (no answer also lets the next call try again at once).
|
||||
*/
|
||||
override suspend fun check(): UpdateCheck {
|
||||
if (debugBuild) return UpdateCheck.UpToDate
|
||||
val now = clock.nowMillis()
|
||||
lastCheckAt?.let { if (now - it < CHECK_INTERVAL_MILLIS) return }
|
||||
lastCheckAt?.let { if (now - it < CHECK_INTERVAL_MILLIS) return known() }
|
||||
lastCheckAt = now
|
||||
when (val result = api.apkLatest()) {
|
||||
return when (val result = api.apkLatest()) {
|
||||
is ApiResult.Ok -> {
|
||||
val dto = result.value
|
||||
if (dto.versionCode > installedVersionCode && FILE_NAME.matches(dto.fileName)) {
|
||||
@@ -90,15 +96,24 @@ class UpdateRepositoryImpl(
|
||||
latest = null
|
||||
mutable.value = UpdateState.None
|
||||
}
|
||||
known()
|
||||
}
|
||||
is ApiResult.Failure -> if (result.code == ErrorCodes.NOT_FOUND) {
|
||||
latest = null
|
||||
mutable.value = UpdateState.None
|
||||
UpdateCheck.UpToDate
|
||||
} else {
|
||||
UpdateCheck.Failed
|
||||
}
|
||||
is ApiResult.NetworkError, ApiResult.NotPaired -> {
|
||||
lastCheckAt = null
|
||||
UpdateCheck.Failed
|
||||
}
|
||||
is ApiResult.NetworkError, ApiResult.NotPaired -> lastCheckAt = null
|
||||
}
|
||||
}
|
||||
|
||||
private fun known(): UpdateCheck = latest?.let { UpdateCheck.Available(it.info()) } ?: UpdateCheck.UpToDate
|
||||
|
||||
override suspend fun install() {
|
||||
val dto = latest ?: return
|
||||
val info = dto.info()
|
||||
|
||||
@@ -144,6 +144,7 @@ class Harness {
|
||||
{ credentials.credentials()?.let { ApiTarget(it.baseUrl, it.token) } },
|
||||
object : ApiClientInfo {
|
||||
override val appVersionCode = APP_VERSION
|
||||
override val appVersionName = APP_VERSION_NAME
|
||||
override fun acceptLanguage() = "sv-SE"
|
||||
},
|
||||
session,
|
||||
@@ -186,6 +187,7 @@ class Harness {
|
||||
|
||||
companion object {
|
||||
const val APP_VERSION = 41
|
||||
const val APP_VERSION_NAME = "1.1"
|
||||
const val BASE_URL = "https://kvitt.test:8443"
|
||||
const val TOKEN = "kvt_testtoken0000000000000000000000000000000000"
|
||||
const val ME = "0192a000-0000-7000-8000-000000000001"
|
||||
|
||||
@@ -40,12 +40,22 @@ class KvittApiTest {
|
||||
for (r in h.requests) {
|
||||
assertEquals("1", r.headers[KvittApi.PROTOCOL_HEADER])
|
||||
assertEquals(Harness.APP_VERSION.toString(), r.headers[KvittApi.APP_VERSION_HEADER])
|
||||
assertEquals(Harness.APP_VERSION_NAME, r.headers[KvittApi.APP_VERSION_NAME_HEADER])
|
||||
assertEquals("sv-SE", r.headers[HttpHeaders.AcceptLanguage])
|
||||
assertEquals("Bearer ${Harness.TOKEN}", r.headers[HttpHeaders.Authorization])
|
||||
assertFalse("the token is never in a URL", r.url.toString().contains(Harness.TOKEN))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun versionNameHeaderIsSentOnlyWhenTheServerWouldStoreIt() {
|
||||
assertEquals("1.1", KvittApi.versionNameHeader(" 1.1 "))
|
||||
assertEquals("dev", KvittApi.versionNameHeader("dev"))
|
||||
assertNull(KvittApi.versionNameHeader(" "))
|
||||
assertNull(KvittApi.versionNameHeader("9".repeat(33)))
|
||||
assertNull(KvittApi.versionNameHeader("1.1-å"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun acceptInviteIsUnauthenticated() = runSync {
|
||||
h.server.restHandler = { _, path -> if (path == "/invites/accept") HttpStatusCode.BadRequest to """{"code":"invite_invalid","message":"x","field":null}""" else null }
|
||||
|
||||
@@ -5,6 +5,8 @@ import io.ktor.http.HttpStatusCode
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import org.junit.After
|
||||
import se.hagstedt.kvitt.data.json
|
||||
import se.hagstedt.kvitt.data.sync.SyncEngine
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
@@ -214,6 +216,29 @@ class RepositoriesTest {
|
||||
assertEquals(0L, home().home().first { it.sync.pendingCount == 2 }.global?.netOre)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settleState_listsTheAccountsTheSettlementLeavesUntouched() = runSync {
|
||||
h.server.addAccount(OLDER, "Hemma", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-09-01T00:00:00.000Z")
|
||||
// Vardag: Anna owes me 1001. Hemma: I owe Anna 500. Globally Anna pays 501, all into Vardag.
|
||||
h.server.storeExpense(serverExpense("v1", 2002))
|
||||
h.server.storeExpense(
|
||||
ExpenseDto(
|
||||
"h1", OLDER, GROCERIES, 1000, "Hemma", "", "2026-10-10", PARTNER, "default",
|
||||
listOf(SplitDto(ME, 500, 5000), SplitDto(PARTNER, 500, 5000)), null, null, PARTNER, PARTNER, FakeServer.T0, FakeServer.T0, null, 0,
|
||||
),
|
||||
)
|
||||
h.engine.sync()
|
||||
h.local.snapshot.first { it.liveExpenses.size == 2 }
|
||||
val state = h.settlementRepo().start(null, null)
|
||||
assertEquals(PARTNER, state.payer.id.value)
|
||||
assertEquals(listOf(ACCOUNT), state.lines.map { it.account.id.value })
|
||||
assertEquals(listOf(OLDER to 500L), state.untouched.map { it.account.id.value to it.payerNetOre })
|
||||
// Rows naming Hemma touch it; a per-account settle-up lists nothing.
|
||||
val both = h.settlementRepo().recompute(se.hagstedt.kvitt.model.SettleRequest(null, state.payer.id, 501, null, mapOf(AccountId(ACCOUNT) to 1L, AccountId(OLDER) to 500L), state.date, ""))
|
||||
assertTrue(both.untouched.isEmpty())
|
||||
assertTrue(h.settlementRepo().start(null, AccountId(ACCOUNT)).untouched.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun accountLedger_settlementRowCarriesTheAllocationToThatAccount() = runSync {
|
||||
h.server.addAccount(OLDER, "Hemma", mapOf(ME to 5000, PARTNER to 5000))
|
||||
@@ -267,6 +292,47 @@ class RepositoriesTest {
|
||||
assertTrue(repo.status().first { it.rejections.isEmpty() }.rejections.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStatus_aRejectionNamesWhoMadeTheServersVersion() = runSync {
|
||||
val id = h.addExpense("ICA")
|
||||
h.server.pushOverride = { request ->
|
||||
val m = request.mutations.single()
|
||||
val current = serverExpense(id, 2000).copy(updatedByUserId = PARTNER)
|
||||
se.hagstedt.kvitt.data.api.PushResponse(Timestamps.format(h.clock.now), listOf(FakeServer.rejected(m.mutationId, "expense", id, "stale", current.json())))
|
||||
}
|
||||
val repo = SyncRepositoryImpl(h.local, h.db, SyncSummaries(h.db, h.engine), h.engine, h.work)
|
||||
repo.syncNow()
|
||||
val rejection = repo.status().first { it.rejections.isNotEmpty() }.rejections.single()
|
||||
assertEquals(SyncRejectionReason.STALE, rejection.reason)
|
||||
assertEquals(PARTNER, rejection.actor?.id?.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncSummary_lastAttemptFailed_afterAFailedCycle_untilOneSucceeds() = runSync {
|
||||
val repo = SyncRepositoryImpl(h.local, h.db, SyncSummaries(h.db, h.engine), h.engine, h.work)
|
||||
assertFalse(repo.status().first().summary.lastAttemptFailed)
|
||||
h.clock.advance(60_000)
|
||||
h.server.failNetwork = true
|
||||
repo.syncNow()
|
||||
assertTrue(repo.status().first { it.summary.lastAttemptFailed }.summary.lastAttemptFailed)
|
||||
h.clock.advance(60_000)
|
||||
h.server.failNetwork = false
|
||||
repo.syncNow()
|
||||
assertFalse(repo.status().first { !it.summary.lastAttemptFailed }.summary.lastAttemptFailed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun lastAttemptFailed_rules() {
|
||||
val base = SyncEngine.INITIAL_STATE
|
||||
assertFalse("never tried", SyncSummaries.lastAttemptFailed(null, running = false))
|
||||
assertFalse("never tried", SyncSummaries.lastAttemptFailed(base, running = false))
|
||||
assertTrue("first sync failed", SyncSummaries.lastAttemptFailed(base.copy(lastAttemptAt = "2026-10-17T10:00:00.000Z"), running = false))
|
||||
assertFalse("running", SyncSummaries.lastAttemptFailed(base.copy(lastAttemptAt = "2026-10-17T10:00:00.000Z"), running = true))
|
||||
val ok = base.copy(lastAttemptAt = "2026-10-17T10:00:00.000Z", lastSuccessAt = "2026-10-17T10:00:01.000Z")
|
||||
assertFalse("succeeded", SyncSummaries.lastAttemptFailed(ok, running = false))
|
||||
assertTrue("a later attempt failed", SyncSummaries.lastAttemptFailed(ok.copy(lastAttemptAt = "2026-10-17T10:05:00.000Z"), running = false))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun expenseDetail_timelineResolvesNames_andEndsWithThePendingLine() = runSync {
|
||||
h.server.storeExpense(serverExpense("e1", 1000))
|
||||
|
||||
@@ -21,6 +21,7 @@ import se.hagstedt.kvitt.data.api.KvittJson
|
||||
import se.hagstedt.kvitt.data.auth.PairingStatus
|
||||
import se.hagstedt.kvitt.data.runSync
|
||||
import se.hagstedt.kvitt.data.sync.SyncTriggers
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.SessionState
|
||||
|
||||
@@ -42,13 +43,13 @@ class SessionRepositoryTest {
|
||||
private fun repo(debug: Boolean = false) =
|
||||
SessionRepositoryImpl(h.credentials, h.db, h.api, h.gate, h.engine, SyncTriggers(h.work, h.engine, h.scope), h.wipe, h.clock, debug)
|
||||
|
||||
private fun acceptAs(userId: String) {
|
||||
private fun acceptAs(userId: String, kind: String = "admin_setup") {
|
||||
val user = h.server.users.getValue(userId)
|
||||
h.server.restHandler = { _, path ->
|
||||
if (path == "/invites/accept") {
|
||||
HttpStatusCode.OK to KvittJson.encodeToString(
|
||||
AcceptInviteResponse.serializer(),
|
||||
AcceptInviteResponse("kvt_new", user, DeviceDto("d2", userId, "Galaxy", 41, "2026-10-17T00:00:00.000Z", "2026-10-17T00:00:00.000Z", null, true), h.server.users.values.toList()),
|
||||
AcceptInviteResponse("kvt_new", kind, user, DeviceDto("d2", userId, "Galaxy", 41, "1.1", "2026-10-17T00:00:00.000Z", "2026-10-17T00:00:00.000Z", null, true), h.server.users.values.toList()),
|
||||
)
|
||||
} else {
|
||||
null
|
||||
@@ -60,7 +61,8 @@ class SessionRepositoryTest {
|
||||
fun pair_storesCredentials_writesTheHousehold_andStartsAFullSync() = runSync {
|
||||
acceptAs(ME)
|
||||
val result = repo().pair("https://kvitt.test:8443/ ", "7k3m-qx9d-2hpa", "Jacob", "Galaxy S26")
|
||||
assertEquals(ME, (result as OnlineResult.Ok).value.id.value)
|
||||
assertEquals(ME, (result as OnlineResult.Ok).value.user.id.value)
|
||||
assertEquals(InviteKind.ADMIN_SETUP, result.value.inviteKind)
|
||||
val request = h.requests.single()
|
||||
assertEquals("https://kvitt.test:8443/invites/accept", request.url.toString())
|
||||
val credentials = h.credentials.credentials()
|
||||
@@ -74,6 +76,15 @@ class SessionRepositoryTest {
|
||||
assertEquals(listOf(false), h.work.oneOff)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pair_reportsTheInviteKind_soOnlyAPartnerPairingShowsTheProfileStep() = runSync {
|
||||
acceptAs(PARTNER, "partner")
|
||||
assertEquals(InviteKind.PARTNER, (repo().pair("https://kvitt.test", "7K3MQX9D2HPA", "Anna", "Galaxy") as OnlineResult.Ok).value.inviteKind)
|
||||
acceptAs(PARTNER, "device")
|
||||
assertEquals(InviteKind.DEVICE, (repo().pair("https://kvitt.test", "7K3MQX9D2HPA", null, "Galaxy") as OnlineResult.Ok).value.inviteKind)
|
||||
assertEquals(InviteKind.DEVICE, SessionRepositoryImpl.inviteKindOf("something_new"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun pair_normalisesTheCode_andRefusesBadInput_withoutARequest() = runSync {
|
||||
assertEquals("7K3MQX9D2HPA", SessionRepositoryImpl.normaliseInviteCode(" 7k3m-qx9d 2hpa "))
|
||||
|
||||
@@ -341,6 +341,38 @@ class SyncEngineTest {
|
||||
assertEquals(false, h.db.syncState().get()?.clockSkew)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun push_afterClockSkew_restampsRowsStampedAhead_soACorrectedClockHealsThem() = runSync {
|
||||
h.ready()
|
||||
h.clock.advance(HOUR)
|
||||
val ahead = h.addExpense("stamped by a clock an hour ahead")
|
||||
h.clock.advance(-HOUR)
|
||||
val onTime = h.addExpense("stamped on time")
|
||||
val onTimeStamp = h.db.expenses().get(onTime)?.updatedAt
|
||||
assertEquals(SyncOutcome.Stopped(SyncEngine.CLOCK_SKEW), h.engine.sync())
|
||||
assertEquals(true, h.db.syncState().get()?.clockSkew)
|
||||
|
||||
// D-027: the next attempt, with the clock still correct, re-stamps only the row from the future.
|
||||
h.clock.advance(60_000)
|
||||
assertEquals(SyncOutcome.Success, h.engine.sync())
|
||||
val healed = h.db.expenses().get(ahead)
|
||||
assertEquals(false, healed?.pending)
|
||||
assertEquals(Timestamps.format(h.clock.now), h.server.pushes.last().mutations.single { it.entity["id"]?.toString()?.trim('"') == ahead }.entity["updated_at"]?.toString()?.trim('"'))
|
||||
assertEquals("a row stamped on time keeps its stamp", onTimeStamp, h.db.expenses().get(onTime)?.updatedAt)
|
||||
assertEquals(false, h.db.syncState().get()?.clockSkew)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun push_withoutAClockSkewFlag_neverRestamps() = runSync {
|
||||
h.ready()
|
||||
h.clock.advance(5 * 60_000L)
|
||||
val id = h.addExpense("five minutes ahead, inside the server's window")
|
||||
h.clock.advance(-5 * 60_000L)
|
||||
val stamp = h.db.expenses().get(id)?.updatedAt
|
||||
assertEquals(SyncOutcome.Success, h.engine.sync())
|
||||
assertEquals(stamp, h.server.pushes.single().mutations.single().entity["updated_at"]?.toString()?.trim('"'))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun push_networkError_keepsEverythingPending_andAsksForRetry() = runSync {
|
||||
h.ready()
|
||||
|
||||
@@ -15,6 +15,7 @@ import se.hagstedt.kvitt.data.Harness
|
||||
import se.hagstedt.kvitt.data.api.ApkLatestDto
|
||||
import se.hagstedt.kvitt.data.api.KvittJson
|
||||
import se.hagstedt.kvitt.data.runSync
|
||||
import se.hagstedt.kvitt.model.UpdateCheck
|
||||
import se.hagstedt.kvitt.model.UpdateInfo
|
||||
import se.hagstedt.kvitt.model.UpdateState
|
||||
import java.io.File
|
||||
@@ -70,14 +71,32 @@ class UpdaterTest {
|
||||
fun newerVersionIsAvailable_sameIsNone() = runSync {
|
||||
serve(latest(Harness.APP_VERSION + 1))
|
||||
val repo = repo()
|
||||
repo.check()
|
||||
assertEquals(UpdateState.Available(UpdateInfo(Harness.APP_VERSION + 1, "2026.11.02-1a2b3c4", apk.size.toLong())), repo.state.first())
|
||||
val info = UpdateInfo(Harness.APP_VERSION + 1, "2026.11.02-1a2b3c4", apk.size.toLong())
|
||||
assertEquals(UpdateCheck.Available(info), repo.check())
|
||||
assertEquals(UpdateState.Available(info), repo.state.first())
|
||||
val other = repo()
|
||||
serve(latest(Harness.APP_VERSION))
|
||||
other.check()
|
||||
assertEquals(UpdateCheck.UpToDate, other.check())
|
||||
assertEquals(UpdateState.None, other.state.first())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checkResult_tellsUpToDateFromAFailure() = runSync {
|
||||
h.server.restHandler = { _, path -> if (path == "/apk/latest") HttpStatusCode.NotFound to """{"code":"not_found","message":"x","field":null}""" else null }
|
||||
assertEquals("nothing published", UpdateCheck.UpToDate, repo().check())
|
||||
h.server.restHandler = { _, path -> if (path == "/apk/latest") HttpStatusCode.InternalServerError to """{"code":"internal","message":"x","field":null}""" else null }
|
||||
assertEquals(UpdateCheck.Failed, repo().check())
|
||||
h.server.failNetwork = true
|
||||
val offline = repo()
|
||||
assertEquals(UpdateCheck.Failed, offline.check())
|
||||
assertEquals(UpdateState.None, offline.state.first())
|
||||
h.server.failNetwork = false
|
||||
serve(latest(Harness.APP_VERSION + 1))
|
||||
assertTrue("no answer: the next call asks again", offline.check() is UpdateCheck.Available)
|
||||
assertTrue("within 6 hours: the stored answer", offline.check() is UpdateCheck.Available)
|
||||
assertEquals(UpdateCheck.UpToDate, repo(debug = true).check())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun checksAtMostEverySixHours() = runSync {
|
||||
serve(latest(Harness.APP_VERSION + 1))
|
||||
|
||||
Reference in New Issue
Block a user