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:
2026-10-18 15:00:50 +02:00
parent 6159e18198
commit 8c607153c0
96 changed files with 1090 additions and 298 deletions
@@ -29,6 +29,21 @@ class InviteLinkTest {
assertNull(InviteLink.parse("kvitt://other?code=7K3MQX9D2HPA"))
}
@Test
fun linksMayNameTheAdmin_andABadNameIsDropped() {
assertEquals(
PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Jacob"),
InviteLink.parse("kvitt://invite?server=https%3A%2F%2Ficehack.asuscomm.com%3A8443&code=7K3MQX9D2HPA&from=Jacob"),
)
assertEquals(
PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL, "Åsa Berg"),
InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA?from=%C3%85sa+Berg"),
)
for (bad in listOf("", "%20", "a%0Ab", "A".repeat(41))) {
assertEquals(bad, PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA?from=$bad"))
}
}
@Test
fun theLandingLinkGivesCodeAndServer() {
assertEquals(PastedInvite("7K3MQX9D2HPA", RELEASE_SERVER_URL), InviteLink.parse("https://icehack.asuscomm.com:8443/i/7K3MQX9D2HPA"))
@@ -18,6 +18,7 @@ import se.hagstedt.kvitt.fakes.onboarding.CallOutcome
import se.hagstedt.kvitt.fakes.onboarding.OnboardingScenarios
import se.hagstedt.kvitt.fakes.onboarding.PairCall
import se.hagstedt.kvitt.fakes.onboarding.ScriptedSessionRepository
import se.hagstedt.kvitt.model.InviteKind
@OptIn(ExperimentalCoroutinesApi::class)
class PairingViewModelTest {
@@ -68,6 +69,16 @@ class PairingViewModelTest {
assertEquals(PairingDone.ProfileStep, vm.state.value.done)
}
@Test
fun aDeviceInviteGoesHomeEvenForTheMember_andTheLinkNamesTheAdmin() {
val (_, vm) = vm(OnboardingScenarios.unpairedPartner.copy(pairingInviteKind = InviteKind.DEVICE))
vm.prefill(RELEASE_SERVER_URL, "7K3MQX9D2HPA", "Jacob")
assertEquals("Jacob", vm.state.value.adminName)
vm.onNameChange("Anna")
vm.pair("Galaxy S26+")
assertEquals(PairingDone.Home, vm.state.value.done)
}
@Test
fun theAdminGoesHomeAndTheAgainVariantSendsNoName() {
val (_, admin) = vm(OnboardingScenarios.unpaired)
@@ -76,6 +76,14 @@ object OnboardingStates {
ScreenState("s1_pairing_empty", render = pairing(OnboardingScenarios.unpaired) {}),
ScreenState("s1_pairing", largeFont = true, render = pairing(OnboardingScenarios.unpaired) { fillFromLink() }),
ScreenState("s1_pairing_link_lan", render = pairing(OnboardingScenarios.unpaired, allowLanHttp = true) { fillFromLink(LAN_SERVER) }),
// AC-2.6: a link that names the inviting admin fills the help line with the name.
ScreenState(
"s1_pairing_link_admin",
render = pairing(OnboardingScenarios.unpaired) {
prefill(RELEASE_SERVER_URL, CODE, "Jacob")
onNameChange("Anna")
},
),
ScreenState("s1_pairing_loading", render = pairing(OnboardingScenarios.unpaired, CallOutcome.Hang) { fillFromLink(); pair("Galaxy S26") }),
ScreenState(
"s1_pairing_error",