fix(settings): AI, Gmail og kalenderen delte én række og slettede hinanden

Meldt ind som "AI-informationerne gemmes ikke". De blev gemt. De blev taget
bagefter.

`user_settings` blev oprettet med `user_id` som hele sin primærnøgle — én række
per person, uanset scope — og upserten skrev `scope` om ved konflikt. Der er tre
scopes i live: `ai`, `gmail` og `calendar`. De delte den ene række, så hver
skrivning slettede de to andre, og hver eneste svarede 204. Det virker begge
veje: at forbinde kalenderen slettede Gmail-forbindelsen, og Gmail slettede
kalenderen.

Fælden var set komme to gange. 0011 gav instansens indstillinger deres egen
tabel delvis af den grund og skrev advarslen ned med rene ord; 0014 flyttede
postkasserne ud og skrev, at advarslen "nær var trådt i for anden gang". Tredje
gang var kalenderen, som lagde et `calendar`-scope ved siden af de to andre.

Lektien værd at holde fast i: en advarsel i en migration bliver læst af den, der
redigerer *den* migration, og det var ingen. Formen er rettet her, så der ikke er
noget tilbage at advare om — `PRIMARY KEY (user_id, scope)`, og upserten rammer
den nøgle.

Tabellen bygges om, fordi SQLite ikke kan ændre en primærnøgle på plads. De rækker,
der findes, kopieres som de er: hver person har højst én, og den holder det scope,
der vandt sidst. Det, de tidligere skrivninger tog, er overskrevet og kan ikke
hentes tilbage her — efter opdateringen skal AI-udbyderen sættes op igen, og
Gmail-forbindelsen tjekkes.

To prøver: én mod store-laget, hvor formen var fejlen, og én gennem handlerne,
hvor den blev set.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
Kristian Wind
2026-08-20 14:56:48 +02:00
co-authored by Claude Opus 5
parent e46aaa462a
commit 6b4448b245
5 changed files with 152 additions and 3 deletions
+62
View File
@@ -124,3 +124,65 @@ func sealedStore(t *testing.T) (*DB, string) {
}
return db, user.ID
}
// Three integrations, one person, and none of them may delete the others.
//
// `user_settings` had `user_id` as its whole primary key and an upsert that
// rewrote `scope` on conflict, so every person had exactly one settings row and
// the last write won the lot: saving an AI provider deleted the Gmail connection,
// and connecting a calendar deleted the AI provider. The write reported success
// each time, which is why it was reported as "the AI settings are not saved"
// rather than as "connecting the calendar deleted them".
//
// Written against the store rather than the handlers, because the shape was the
// bug: every caller was correct on its own.
func TestOnePersonCanHaveSettingsForMoreThanOneThing(t *testing.T) {
db, userID := sealedStore(t)
ctx := context.Background()
want := map[string]map[string]any{
"ai": {"provider": "anthropic", "model": "claude-sonnet-5", "api_key": "sk-ant-xxx"},
"gmail": {"refresh_token": "1//0-gmail", "label": "Verdande"},
"calendar": {"refresh_token": "1//0-calendar", "calendar_id": "primary"},
}
// In this order on purpose: the AI settings are written first and read last,
// so a scope that steals the row is caught by the one written earliest.
for _, scope := range []string{"ai", "gmail", "calendar"} {
if err := db.SetUserSettings(ctx, userID, scope, want[scope]); err != nil {
t.Fatalf("save %s: %v", scope, err)
}
}
for _, scope := range []string{"ai", "gmail", "calendar"} {
got, err := db.UserSettings(ctx, userID, scope)
if err != nil {
t.Fatalf("read %s: %v", scope, err)
}
for key, value := range want[scope] {
if got[key] != value {
t.Errorf("%s.%s is %v, want %v", scope, key, got[key], value)
}
}
}
// And a second write to one scope updates it rather than adding another row —
// the upsert still has to upsert.
if err := db.SetUserSettings(ctx, userID, "ai", map[string]any{"provider": "openai"}); err != nil {
t.Fatal(err)
}
var rows int
if err := db.QueryRowContext(ctx,
`SELECT count(*) FROM user_settings WHERE user_id = ?`, userID).Scan(&rows); err != nil {
t.Fatal(err)
}
if rows != 3 {
t.Errorf("the person has %d settings rows, want 3", rows)
}
back, err := db.UserSettings(ctx, userID, "ai")
if err != nil {
t.Fatal(err)
}
if back["provider"] != "openai" {
t.Errorf("the second write did not land: %v", back["provider"])
}
}