M12f: S-16 lost version read-only, S-22 versions from /health, settle start without a partner (D-109 to D-111)
- Rejection.lost: RejectedVersion (the pushed entity from sync_rejections.rejected_payload as a ledger row, account, payer, split, notes, deleted); S-16 "Öppna" shows it read-only in a dialog with "Visa nuvarande version" (S-8/S-10); without one it opens the current version as before. Ledger rows take a null onClick (read-only). - AboutState.schemaVersion; AboutRepositoryImpl calls E-1 /health once per collection when online; header fallback. S-22 shows "1.1.0 (schema 3)". - SettlementRepository.start/recompute return null without a session or partner; save is Invalid(payer); no checkNotNull/requireNotNull in :data. - :feature:home screenshot harness: one more frame and an idle looper before each capture; the committed sv_dark, 200 % and dialog goldens were one variant stale (light instead of dark, en instead of sv_200). - Tests: lost version (stale, refused deletion, unreadable payload), /health online and offline, settle without a partner, category reorder PATCHes, recurring toWire splits, §9.2 effect cases. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TQd3pEP9UeN2uifbHknQ7B
This commit is contained in:
@@ -55,6 +55,10 @@ class KvittApi(
|
||||
suspend fun acceptInvite(baseUrl: String, request: AcceptInviteRequest): ApiResult<AcceptInviteResponse> =
|
||||
exchange(ApiTarget(baseUrl, null), HttpMethod.Post, "/invites/accept", authenticated = false) { jsonBody(request) }
|
||||
|
||||
// E-1: unauthenticated, to the paired server (S-22, D-110).
|
||||
suspend fun health(): ApiResult<HealthDto> =
|
||||
withTarget { exchange(it.copy(token = null), HttpMethod.Get, "/health", authenticated = false) }
|
||||
|
||||
// E-5, E-6: unauthenticated.
|
||||
suspend fun apkLatest(): ApiResult<ApkLatestDto> =
|
||||
withTarget { exchange(it.copy(token = null), HttpMethod.Get, "/apk/latest", authenticated = false) }
|
||||
|
||||
@@ -275,8 +275,8 @@ object DataModule {
|
||||
SyncRepositoryImpl(local, db, summaries, engine, work)
|
||||
|
||||
@Provides @Singleton
|
||||
fun about(credentials: CredentialStore, session: SessionManager, build: AppBuildInfo): AboutRepositoryImpl =
|
||||
AboutRepositoryImpl(credentials, session.serverVersion, build.versionName, build.versionCode, DEFAULT_SERVER_URL)
|
||||
fun about(credentials: CredentialStore, session: SessionManager, api: KvittApi, gate: OnlineGate, build: AppBuildInfo): AboutRepositoryImpl =
|
||||
AboutRepositoryImpl(credentials, session.serverVersion, api, gate, build.versionName, build.versionCode, DEFAULT_SERVER_URL)
|
||||
}
|
||||
|
||||
/** The `:core:model` interfaces bound to `:data`. Not installed automatically: `:app` includes it in its release graph. */
|
||||
|
||||
@@ -4,6 +4,7 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
@@ -14,12 +15,15 @@ import se.hagstedt.kvitt.data.api.CreateInviteRequest
|
||||
import se.hagstedt.kvitt.data.api.CreateTemplateRequest
|
||||
import se.hagstedt.kvitt.data.api.DeviceDto
|
||||
import se.hagstedt.kvitt.data.api.ErrorCodes
|
||||
import se.hagstedt.kvitt.data.api.ExpensePushEntity
|
||||
import se.hagstedt.kvitt.data.api.InviteDto
|
||||
import se.hagstedt.kvitt.data.api.KvittApi
|
||||
import se.hagstedt.kvitt.data.api.KvittJson
|
||||
import se.hagstedt.kvitt.data.api.Patch
|
||||
import se.hagstedt.kvitt.data.api.PatchCategoryRequest
|
||||
import se.hagstedt.kvitt.data.api.PatchMeRequest
|
||||
import se.hagstedt.kvitt.data.api.PatchTemplateRequest
|
||||
import se.hagstedt.kvitt.data.api.SettlementPushEntity
|
||||
import se.hagstedt.kvitt.data.api.TemplateSplitDto
|
||||
import se.hagstedt.kvitt.data.auth.CredentialStore
|
||||
import se.hagstedt.kvitt.data.connectivity.OnlineGate
|
||||
@@ -43,6 +47,7 @@ import se.hagstedt.kvitt.model.DeviceId
|
||||
import se.hagstedt.kvitt.model.DeviceRepository
|
||||
import se.hagstedt.kvitt.model.EntityType
|
||||
import se.hagstedt.kvitt.model.ExpenseDraft
|
||||
import se.hagstedt.kvitt.model.ExpenseId
|
||||
import se.hagstedt.kvitt.model.Invite
|
||||
import se.hagstedt.kvitt.model.InviteId
|
||||
import se.hagstedt.kvitt.model.InviteKind
|
||||
@@ -53,7 +58,10 @@ import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.ProfileRepository
|
||||
import se.hagstedt.kvitt.model.RecurringRepository
|
||||
import se.hagstedt.kvitt.model.RecurringTemplate
|
||||
import se.hagstedt.kvitt.model.RejectedVersion
|
||||
import se.hagstedt.kvitt.model.Rejection
|
||||
import se.hagstedt.kvitt.model.SettlementId
|
||||
import se.hagstedt.kvitt.model.Share
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.model.SyncRejectionReason
|
||||
import se.hagstedt.kvitt.model.SyncRepository
|
||||
@@ -185,7 +193,7 @@ class RecurringRepositoryImpl(
|
||||
}
|
||||
|
||||
/** 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 NewRecurringTemplate.toWire(household: List<UserId>): CreateTemplateRequest? {
|
||||
internal 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
|
||||
@@ -313,6 +321,7 @@ 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). `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.
|
||||
* `lost` is the pushed entity itself (`rejected_payload`), for the read-only view of S-16 (D-109).
|
||||
*/
|
||||
internal fun SyncRejectionEntity.toModel(s: Snapshot): Rejection? {
|
||||
val type = EntityType.entries.firstOrNull { it.name.lowercase() == entityType } ?: return null
|
||||
@@ -323,7 +332,42 @@ internal fun SyncRejectionEntity.toModel(s: Snapshot): Rejection? {
|
||||
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, actor)
|
||||
val lost = payload?.let { s.rejectedVersion(type, it) }
|
||||
return Rejection(mutationId, type, entityId, reason, text.orEmpty(), rejectedAt, actor, lost)
|
||||
}
|
||||
|
||||
/** The pushed entity as the row it would have been; null when it does not decode or its category or users are unknown. */
|
||||
private fun Snapshot.rejectedVersion(type: EntityType, payload: JsonObject): RejectedVersion? = try {
|
||||
when (type) {
|
||||
EntityType.EXPENSE -> {
|
||||
val e = KvittJson.decodeFromJsonElement(ExpensePushEntity.serializer(), payload)
|
||||
val viewer = meId
|
||||
val category = category(e.categoryId)
|
||||
if (viewer == null || category == null) {
|
||||
null
|
||||
} else {
|
||||
val splits = e.splits.map { it.userId to it.shareOre }
|
||||
val row = LedgerRow.ExpenseRow(ExpenseId(e.id), e.expenseDate, e.description, category, e.amountOre, effectOf(e.payerUserId, splits, viewer), pending = false)
|
||||
val shares = e.splits.sortedBy { it.userId }.map { Share(UserId(it.userId), it.shareOre, it.shareBp) }
|
||||
RejectedVersion(row, account(e.accountId)?.name, user(e.payerUserId), splitModeOf(e.splitMode), shares, e.notes, e.deletedAt != null)
|
||||
}
|
||||
}
|
||||
EntityType.SETTLEMENT -> {
|
||||
val st = KvittJson.decodeFromJsonElement(SettlementPushEntity.serializer(), payload)
|
||||
val from = user(st.fromUserId)
|
||||
val to = user(st.toUserId)
|
||||
if (from == null || to == null) {
|
||||
null
|
||||
} else {
|
||||
val row = LedgerRow.SettlementRow(SettlementId(st.id), st.settlementDate, from, to, st.amountOre, st.allocations.size, pending = false)
|
||||
RejectedVersion(row, null, null, null, emptyList(), st.note, st.deletedAt != null)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: kotlinx.serialization.SerializationException) {
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
null
|
||||
}
|
||||
|
||||
private fun jsonObjectOrNull(text: String): JsonObject? = try {
|
||||
@@ -332,15 +376,27 @@ private fun jsonObjectOrNull(text: String): JsonObject? = try {
|
||||
null
|
||||
}
|
||||
|
||||
/** S-22 (FR-16 versions, the paired server and the server's version header). */
|
||||
/**
|
||||
* S-22 (FR-16 versions, the paired server). Each collection (S-22 opening) asks E-1 `/health` once when online, for
|
||||
* `server_version` and `schema_version` (§13.3, D-110); until it answers, offline or when it fails, the server version
|
||||
* is the `X-Kvitt-Server-Version` header of the last response and the schema version is null.
|
||||
*/
|
||||
class AboutRepositoryImpl(
|
||||
private val credentials: CredentialStore,
|
||||
private val serverVersion: Flow<String?>,
|
||||
private val api: KvittApi,
|
||||
private val gate: OnlineGate,
|
||||
private val versionName: String,
|
||||
private val versionCode: Int,
|
||||
private val defaultServerUrl: String,
|
||||
) : AboutRepository {
|
||||
override fun about(): Flow<AboutState> = combine(credentials.record, serverVersion) { record, server ->
|
||||
AboutState(versionName, versionCode, record.baseUrl ?: defaultServerUrl, server)
|
||||
}.distinctUntilChanged()
|
||||
override fun about(): Flow<AboutState> {
|
||||
val health = flow {
|
||||
emit(null)
|
||||
emit((gate.run({ api.health() }) { it } as? OnlineResult.Ok)?.value)
|
||||
}
|
||||
return combine(credentials.record, serverVersion, health) { record, header, h ->
|
||||
AboutState(versionName, versionCode, record.baseUrl ?: defaultServerUrl, h?.serverVersion ?: header, h?.schemaVersion)
|
||||
}.distinctUntilChanged()
|
||||
}
|
||||
}
|
||||
|
||||
+12
-9
@@ -44,10 +44,11 @@ class SettlementRepositoryImpl(
|
||||
) : SettlementRepository {
|
||||
private val timelines = Timelines(db, local)
|
||||
|
||||
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState {
|
||||
/** Null without a paired session or before the partner has joined: S-9 is not offered then (AC-5.1, D-111). */
|
||||
override suspend fun start(editing: SettlementId?, fixedAccount: AccountId?): SettleState? {
|
||||
val s = local.snapshot.first()
|
||||
val me = checkNotNull(s.me) { "Settle up needs a paired session" }
|
||||
val partner = checkNotNull(s.partner) { "Settle up is not offered before the partner has joined (AC-5.1)" }
|
||||
val me = s.me ?: return null
|
||||
val partner = s.partner ?: return null
|
||||
val existing = editing?.let { s.settlement(it.value) }?.takeIf { !it.deleted }
|
||||
if (existing != null) {
|
||||
return s.settleState(
|
||||
@@ -61,11 +62,12 @@ class SettlementRepositoryImpl(
|
||||
return s.settleState(SettleRequest(null, payer, myNet.absoluteValue.takeIf { it > 0 }, fixedAccount, null, clock.today(), ""))
|
||||
}
|
||||
|
||||
override suspend fun recompute(request: SettleRequest): SettleState = local.snapshot.first().settleState(request)
|
||||
override suspend fun recompute(request: SettleRequest): SettleState? = local.snapshot.first().settleState(request)
|
||||
|
||||
override suspend fun save(request: SettleRequest): SaveResult<SettlementId> {
|
||||
val s = local.snapshot.first()
|
||||
val state = s.settleState(request)
|
||||
// D-111: an unknown payer or no partner yet cannot be saved; the screen is not offered then (AC-5.1).
|
||||
val state = s.settleState(request) ?: return SaveResult.Invalid(listOf(FieldError("payer", ExpenseRepositoryImpl.VALIDATION_FAILED)))
|
||||
val errors = mutableListOf<FieldError>()
|
||||
val amount = request.amountOre
|
||||
if (amount == null) {
|
||||
@@ -145,11 +147,12 @@ private fun SettleState.allocations(): List<Allocation> = lines.map { Allocation
|
||||
|
||||
/**
|
||||
* §9.4 for a request: with `allocations == null` the proposal of `proposeAllocation`, otherwise the given rows; a
|
||||
* per-account settle-up has exactly one row (AC-11.4). Nets exclude the settlement being edited (AC-11.6).
|
||||
* per-account settle-up has exactly one row (AC-11.4). Nets exclude the settlement being edited (AC-11.6). Null when
|
||||
* the payer is not a household user or there is no recipient yet (D-111).
|
||||
*/
|
||||
internal fun Snapshot.settleState(request: SettleRequest): SettleState {
|
||||
val payer = requireNotNull(user(request.payer.value)) { "unknown payer" }
|
||||
val recipient = requireNotNull(household.firstOrNull { it.id != request.payer }) { "no recipient before the partner joined" }
|
||||
internal fun Snapshot.settleState(request: SettleRequest): SettleState? {
|
||||
val payer = user(request.payer.value) ?: return null
|
||||
val recipient = household.firstOrNull { it.id != request.payer } ?: return null
|
||||
val balances = balances(excludingSettlement = request.editing?.value)
|
||||
val positions = accounts.map { AccountPosition(it.id.value, it.createdAt, it.archived, balances.net(it.id.value, request.payer)) }
|
||||
val amount = request.amountOre
|
||||
|
||||
@@ -121,15 +121,7 @@ data class Snapshot(
|
||||
)
|
||||
|
||||
/** §9.2 from the viewer's side. */
|
||||
fun effectFor(e: ExpenseRecord, viewer: UserId): ExpenseEffect {
|
||||
val payer = e.row.payerUserId
|
||||
val nonPayerShare = e.splits.firstOrNull { it.userId != payer }?.shareOre ?: 0L
|
||||
return when {
|
||||
nonPayerShare == 0L -> if (viewer.value == payer) ExpenseEffect.PaidForYourself else ExpenseEffect.NotInvolved
|
||||
viewer.value == payer -> ExpenseEffect.Lent(nonPayerShare)
|
||||
else -> ExpenseEffect.Borrowed(e.splits.firstOrNull { it.userId == viewer.value }?.shareOre ?: 0L)
|
||||
}
|
||||
}
|
||||
fun effectFor(e: ExpenseRecord, viewer: UserId): ExpenseEffect = effectOf(e.row.payerUserId, e.splits.map { it.userId to it.shareOre }, viewer)
|
||||
|
||||
/** A ledger row, or null while its category has not arrived (no foreign keys, §14.2). */
|
||||
fun expenseRow(e: ExpenseRecord): LedgerRow.ExpenseRow? {
|
||||
@@ -147,6 +139,16 @@ data class Snapshot(
|
||||
}
|
||||
}
|
||||
|
||||
/** §9.2 for a payer and the (user id, share in öre) pairs of an expense, from [viewer]'s side. */
|
||||
fun effectOf(payer: String, splits: List<Pair<String, Long>>, viewer: UserId): ExpenseEffect {
|
||||
val nonPayerShare = splits.firstOrNull { it.first != payer }?.second ?: 0L
|
||||
return when {
|
||||
nonPayerShare == 0L -> if (viewer.value == payer) ExpenseEffect.PaidForYourself else ExpenseEffect.NotInvolved
|
||||
viewer.value == payer -> ExpenseEffect.Lent(nonPayerShare)
|
||||
else -> ExpenseEffect.Borrowed(splits.firstOrNull { it.first == viewer.value }?.second ?: 0L)
|
||||
}
|
||||
}
|
||||
|
||||
/** AC-8.10: date descending, then `created_at` descending. */
|
||||
val ledgerOrder: Comparator<Pair<String, String>> = compareByDescending<Pair<String, String>> { it.first }.thenByDescending { it.second }
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ import se.hagstedt.kvitt.data.api.KvittJson
|
||||
import se.hagstedt.kvitt.data.api.SettlementDto
|
||||
import se.hagstedt.kvitt.data.api.ShareBpDto
|
||||
import se.hagstedt.kvitt.data.api.SplitDto
|
||||
import se.hagstedt.kvitt.data.api.TemplateSplitDto
|
||||
import se.hagstedt.kvitt.data.draft
|
||||
import se.hagstedt.kvitt.data.expenseRepo
|
||||
import se.hagstedt.kvitt.data.runSync
|
||||
@@ -44,15 +45,18 @@ import se.hagstedt.kvitt.model.AccountId
|
||||
import se.hagstedt.kvitt.model.AnalyticsFilter
|
||||
import se.hagstedt.kvitt.model.AuditAction
|
||||
import se.hagstedt.kvitt.model.CategoryId
|
||||
import se.hagstedt.kvitt.model.ExpenseEffect
|
||||
import se.hagstedt.kvitt.model.ExpenseId
|
||||
import se.hagstedt.kvitt.model.FieldError
|
||||
import se.hagstedt.kvitt.model.Granularity
|
||||
import se.hagstedt.kvitt.model.GroupBy
|
||||
import se.hagstedt.kvitt.model.LedgerRow
|
||||
import se.hagstedt.kvitt.model.Measure
|
||||
import se.hagstedt.kvitt.model.NewRecurringTemplate
|
||||
import se.hagstedt.kvitt.model.OnlineResult
|
||||
import se.hagstedt.kvitt.model.RangePreset
|
||||
import se.hagstedt.kvitt.model.SaveResult
|
||||
import se.hagstedt.kvitt.model.SplitMode
|
||||
import se.hagstedt.kvitt.model.SyncRejectionReason
|
||||
import se.hagstedt.kvitt.model.UpdateState
|
||||
import se.hagstedt.kvitt.model.UserId
|
||||
@@ -206,7 +210,7 @@ class RepositoriesTest {
|
||||
fun settleStart_theUserWhoOwesPays_amountIsTheBalance() = runSync {
|
||||
h.addExpense("ICA", amountOre = 1001)
|
||||
h.local.snapshot.first { it.liveExpenses.isNotEmpty() }
|
||||
val state = h.settlementRepo().start(null, null)
|
||||
val state = requireNotNull(h.settlementRepo().start(null, null))
|
||||
assertEquals(PARTNER, state.payer.id.value)
|
||||
assertEquals(500L, state.amountOre)
|
||||
assertEquals(listOf(500L), state.lines.map { it.amountOre })
|
||||
@@ -216,6 +220,25 @@ class RepositoriesTest {
|
||||
assertEquals(0L, home().home().first { it.sync.pendingCount == 2 }.global?.netOre)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settleStart_withoutAPartner_isNull_andSaveIsInvalid_neverAThrow() = runSync {
|
||||
val alone = Harness()
|
||||
try {
|
||||
alone.server.addUser(ME, "admin", "Jacob")
|
||||
alone.server.addCategory(OTHER, "other", null)
|
||||
alone.pair()
|
||||
alone.engine.sync()
|
||||
alone.local.snapshot.first { it.me != null }
|
||||
assertNull("D-111: no partner yet (AC-5.1)", alone.settlementRepo().start(null, null))
|
||||
val request = se.hagstedt.kvitt.model.SettleRequest(null, UserId(ME), 100, null, null, "2026-10-17", "")
|
||||
assertNull(alone.settlementRepo().recompute(request))
|
||||
val saved = alone.settlementRepo().save(request)
|
||||
assertEquals(SaveResult.Invalid(listOf(FieldError("payer", "validation_failed"))), saved)
|
||||
} finally {
|
||||
alone.close()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settleState_listsTheAccountsTheSettlementLeavesUntouched() = runSync {
|
||||
h.server.addAccount(OLDER, "Hemma", mapOf(ME to 5000, PARTNER to 5000), createdAt = "2026-09-01T00:00:00.000Z")
|
||||
@@ -229,14 +252,14 @@ class RepositoriesTest {
|
||||
)
|
||||
h.engine.sync()
|
||||
h.local.snapshot.first { it.liveExpenses.size == 2 }
|
||||
val state = h.settlementRepo().start(null, null)
|
||||
val state = requireNotNull(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, ""))
|
||||
val both = requireNotNull(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())
|
||||
assertTrue(requireNotNull(h.settlementRepo().start(null, AccountId(ACCOUNT))).untouched.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -305,6 +328,148 @@ class RepositoriesTest {
|
||||
val rejection = repo.status().first { it.rejections.isNotEmpty() }.rejections.single()
|
||||
assertEquals(SyncRejectionReason.STALE, rejection.reason)
|
||||
assertEquals(PARTNER, rejection.actor?.id?.value)
|
||||
// D-109: the phone's lost version, from rejected_payload, not the server's winning row.
|
||||
val lost = requireNotNull(rejection.lost)
|
||||
val row = lost.row as LedgerRow.ExpenseRow
|
||||
assertEquals("ICA", row.description)
|
||||
assertEquals(41200L, row.amountOre)
|
||||
assertEquals(GROCERIES, row.category.id.value)
|
||||
assertEquals(ExpenseEffect.Lent(20600), row.effect)
|
||||
assertFalse(row.pending)
|
||||
assertEquals("Vardag", lost.accountName)
|
||||
assertEquals(ME, lost.payer?.id?.value)
|
||||
assertEquals(SplitMode.DEFAULT, lost.mode)
|
||||
assertEquals(listOf(5000, 5000), lost.shares.map { it.shareBp })
|
||||
assertFalse(lost.deleted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStatus_aRefusedDeletionShowsTheDeletedLostVersion() = runSync {
|
||||
val id = h.addExpense("ICA")
|
||||
h.expenseRepo().delete(ExpenseId(id))
|
||||
h.server.pushOverride = { request ->
|
||||
val m = request.mutations.single()
|
||||
se.hagstedt.kvitt.data.api.PushResponse(Timestamps.format(h.clock.now), listOf(FakeServer.rejected(m.mutationId, "expense", id, "validation_failed", null)))
|
||||
}
|
||||
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()
|
||||
assertTrue(rejection.lost?.deleted == true)
|
||||
assertEquals("ICA", (rejection.lost?.row as LedgerRow.ExpenseRow).description)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun syncStatus_anUnreadablePayloadHasNoLostVersion() = runSync {
|
||||
h.db.rejections().insertOrIgnore(
|
||||
se.hagstedt.kvitt.data.db.SyncRejectionEntity("m-x", "expense", "e-x", "stale", "", "not json", null, "2026-10-17T10:00:00.000Z", false),
|
||||
)
|
||||
val repo = SyncRepositoryImpl(h.local, h.db, SyncSummaries(h.db, h.engine), h.engine, h.work)
|
||||
val rejection = repo.status().first { it.rejections.isNotEmpty() }.rejections.single()
|
||||
assertNull(rejection.lost)
|
||||
assertEquals("", rejection.summary)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun about_readsServerAndSchemaVersionFromHealth_whenOnline() = runSync {
|
||||
h.server.restHandler = { _, path ->
|
||||
if (path == "/health") HttpStatusCode.OK to """{"status":"ok","server_version":"1.2.0","schema_version":3,"db_ok":true}""" else null
|
||||
}
|
||||
val repo = AboutRepositoryImpl(h.credentials, kotlinx.coroutines.flow.MutableStateFlow("1.1.0"), h.api, h.gate, "1.1", 11, "https://default.test")
|
||||
val state = repo.about().first { it.schemaVersion != null }
|
||||
assertEquals("1.2.0", state.serverVersion)
|
||||
assertEquals(3, state.schemaVersion)
|
||||
assertEquals(Harness.BASE_URL, state.serverUrl)
|
||||
val health = h.requests.single { it.url.encodedPath == "/health" }
|
||||
assertNull("E-1 is unauthenticated", health.headers["Authorization"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun about_offlineOrFailingHealth_fallsBackToTheHeaderVersion() = runSync {
|
||||
val header = kotlinx.coroutines.flow.MutableStateFlow<String?>("1.1.0")
|
||||
h.monitor.state.value = false
|
||||
val repo = AboutRepositoryImpl(h.credentials, header, h.api, h.gate, "1.1", 11, "https://default.test")
|
||||
val offline = repo.about().first()
|
||||
assertEquals("1.1.0", offline.serverVersion)
|
||||
assertNull(offline.schemaVersion)
|
||||
assertNull(kotlinx.coroutines.withTimeoutOrNull(300) { repo.about().first { it.schemaVersion != null } })
|
||||
assertTrue("no request offline", h.requests.none { it.url.encodedPath == "/health" })
|
||||
|
||||
h.monitor.state.value = true
|
||||
h.server.restHandler = { _, path -> if (path == "/health") HttpStatusCode.ServiceUnavailable to """{"code":"internal","message":"x","field":null}""" else null }
|
||||
assertNull(kotlinx.coroutines.withTimeoutOrNull(300) { repo.about().first { it.schemaVersion != null } })
|
||||
assertEquals("1.1.0", repo.about().first().serverVersion)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun categoryReorder_sendsOnePatchPerChangedSortOrder() = runSync {
|
||||
val patched = mutableListOf<Pair<String, Int?>>()
|
||||
h.server.restHandler = { request, path ->
|
||||
if (request.method == HttpMethod.Patch && path.startsWith("/categories/")) {
|
||||
val id = path.removePrefix("/categories/")
|
||||
val text = when (val content = request.body) {
|
||||
is io.ktor.http.content.TextContent -> content.text
|
||||
is io.ktor.http.content.OutgoingContent.ByteArrayContent -> String(content.bytes())
|
||||
else -> throw AssertionError("unexpected body ${content::class.java.name}")
|
||||
}
|
||||
val body = KvittJson.parseToJsonElement(text)
|
||||
val order = (body as kotlinx.serialization.json.JsonObject)["sort_order"]?.toString()?.toInt()
|
||||
patched += id to order
|
||||
val updated = h.server.categories.getValue(id).copy(sortOrder = order ?: 0, serverVersion = h.server.next())
|
||||
h.server.categories[id] = updated
|
||||
HttpStatusCode.OK to KvittJson.encodeToString(se.hagstedt.kvitt.data.api.CategoryDto.serializer(), updated)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
val repo = CategoryRepositoryImpl(h.local, h.api, h.gate, h.db, h.clock)
|
||||
assertEquals(OnlineResult.Ok(Unit), repo.reorder(listOf(CategoryId(GROCERIES), CategoryId(OTHER))))
|
||||
assertTrue("unchanged order: no request", patched.isEmpty())
|
||||
assertEquals(OnlineResult.Ok(Unit), repo.reorder(listOf(CategoryId(OTHER), CategoryId(GROCERIES))))
|
||||
assertEquals(listOf(OTHER to 10, GROCERIES to 20), patched)
|
||||
assertEquals(listOf(OTHER, GROCERIES), repo.all().first { it.first().id.value == OTHER }.map { it.id.value })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recurringToWire_splitsPerMode() {
|
||||
val me = UserId(ME)
|
||||
val partner = UserId(PARTNER)
|
||||
fun template(mode: SplitMode, ratio: Map<UserId, Int>? = null, exact: Map<UserId, Long>? = null, fullOn: UserId? = null) = NewRecurringTemplate(
|
||||
se.hagstedt.kvitt.model.ExpenseDraft(null, AccountId(ACCOUNT), CategoryId(GROCERIES), 10_000, " Hyra ", "", "2026-11-01", me, mode, ratio, exact, fullOn),
|
||||
dayOfMonth = 1, startPeriod = "2026-11", endPeriod = null, paused = false,
|
||||
)
|
||||
val household = listOf(me, partner)
|
||||
val default = requireNotNull(template(SplitMode.DEFAULT).toWire(household))
|
||||
assertTrue("default: no splits", default.splits.isEmpty())
|
||||
assertEquals("Hyra", default.description)
|
||||
assertEquals("default", default.splitMode)
|
||||
val ratio = requireNotNull(template(SplitMode.RATIO, ratio = mapOf(partner to 4_000, me to 6_000)).toWire(household))
|
||||
assertEquals(listOf(TemplateSplitDto(ME, 6_000, null), TemplateSplitDto(PARTNER, 4_000, null)), ratio.splits)
|
||||
val full = requireNotNull(template(SplitMode.FULL, fullOn = partner).toWire(household))
|
||||
assertEquals(listOf(TemplateSplitDto(ME, 0, null), TemplateSplitDto(PARTNER, 10_000, null)), full.splits)
|
||||
val exact = requireNotNull(template(SplitMode.EXACT, exact = mapOf(me to 2_500L, partner to 7_500L)).toWire(household))
|
||||
assertEquals(listOf(TemplateSplitDto(ME, null, 2_500), TemplateSplitDto(PARTNER, null, 7_500)), exact.splits)
|
||||
assertNull("ratio without bp", template(SplitMode.RATIO).toWire(household))
|
||||
assertNull("full without a bearer", template(SplitMode.FULL).toWire(household))
|
||||
assertNull("full before the partner joined", template(SplitMode.FULL, fullOn = me).toWire(listOf(me)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun effectLine_theFourCasesOfSection9_2() = runSync {
|
||||
// §9.2 from the viewer (me): I paid and Anna has a share; Anna paid and I have a share; I paid it all for
|
||||
// myself; Anna paid it all for herself.
|
||||
fun record(payer: String, mine: Long, hers: Long) = ExpenseRecord(
|
||||
se.hagstedt.kvitt.data.db.ExpenseEntity(
|
||||
"e", ACCOUNT, GROCERIES, mine + hers, "x", "", "2026-10-10", payer, "exact", null, null, ME, ME,
|
||||
FakeServer.T0, FakeServer.T0, null, 1, false, null, 0,
|
||||
),
|
||||
listOf(se.hagstedt.kvitt.data.db.ExpenseSplitEntity("e", ME, mine, null), se.hagstedt.kvitt.data.db.ExpenseSplitEntity("e", PARTNER, hers, null)),
|
||||
)
|
||||
val s = h.local.snapshot.first { it.me != null }
|
||||
val me = UserId(ME)
|
||||
assertEquals(ExpenseEffect.Lent(700), s.effectFor(record(ME, 300, 700), me))
|
||||
assertEquals(ExpenseEffect.Borrowed(300), s.effectFor(record(PARTNER, 300, 700), me))
|
||||
assertEquals(ExpenseEffect.PaidForYourself, s.effectFor(record(ME, 1000, 0), me))
|
||||
assertEquals(ExpenseEffect.NotInvolved, s.effectFor(record(PARTNER, 0, 1000), me))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user