package store import ( "database/sql" "testing" ) // voice_test.go: the pack-19 schema, on the two axes that can only be checked by execution — that an // EXISTING database upgrades onto it, and that the bank's three record types are replaced as one unit. // The migration that matters is not the fresh one (every test creates that) but the UPGRADE: a stand // database already at an older version must gain the pack-19 tables and columns without losing a row. func TestMigrationUpgradesAnOlderDatabase(t *testing.T) { const olderVersion = 12 // the schema head before pack-19 (v13 tables + v14 retrieval-state columns) if len(migrations) <= olderVersion { t.Fatalf("this test assumes pack-19 appended to a %d-step schema, got %d", olderVersion, len(migrations)) } path := t.TempDir() + "/old.db" // Build a database at the OLD head by applying only the old steps through a raw connection. raw, err := sql.Open("sqlite", path) if err != nil { t.Fatal(err) } if _, err := raw.Exec(`CREATE TABLE IF NOT EXISTS schema_version (version INTEGER PRIMARY KEY)`); err != nil { t.Fatal(err) } for i := 0; i < olderVersion; i++ { if _, err := raw.Exec(migrations[i]); err != nil { t.Fatalf("apply old migration %d: %v", i+1, err) } if _, err := raw.Exec(`INSERT INTO schema_version (version) VALUES (?)`, i+1); err != nil { t.Fatal(err) } } // A row written under the OLD schema must survive the upgrade untouched. if _, err := raw.Exec(`INSERT INTO glossary (book_id, src, dst, status) VALUES ('b','旧','старое','approved')`); err != nil { t.Fatal(err) } // Premise check: at the OLD head the pack-19 schema must genuinely be absent, or this test would // prove nothing about upgrading. A successful query means it is already there. mustNotQuery := func(query, why string) { t.Helper() rows, qerr := raw.Query(query) if qerr != nil { return // the column/table is absent — exactly the premise this test needs } defer rows.Close() if rows.Err() == nil { t.Fatal(why) } } mustNotQuery(`SELECT n_voice_flags FROM retrieval_state`, "the old schema already has the pack-19 columns — the upgrade is not being exercised") mustNotQuery(`SELECT 1 FROM voice_profiles`, "the old schema already has voice_profiles — the upgrade is not being exercised") if err := raw.Close(); err != nil { t.Fatal(err) } s, err := Open(path) // runs the pending migrations if err != nil { t.Fatalf("an existing database must upgrade onto the pack-19 schema: %v", err) } defer s.Close() rows, err := s.GlossaryForBook("b") if err != nil || len(rows) != 1 || rows[0].Dst != "старое" { t.Fatalf("the pre-existing row must survive the upgrade: %v %+v", err, rows) } // The new tables and columns are usable, not merely present. if err := s.ReplaceBank("b", rows, []VoiceProfile{{Src: "旧", SelfRef: "ваша служанка"}}, []AddressPair{{SpeakerSrc: "旧", AddresseeSrc: "новое", Register: "formal"}}); err != nil { t.Fatalf("write to the upgraded schema: %v", err) } if v, err := s.VoiceProfilesForBook("b"); err != nil || len(v) != 1 { t.Fatalf("voice_profiles unusable after the upgrade: %v %+v", err, v) } if err := s.UpsertRetrievalState(RetrievalState{ BookID: "b", Chapter: 1, ChunkIdx: 0, SnapshotID: "s", NVoiceFlags: 3, VoiceDetail: `{"replies":2}`, NSpoilerLeaks: 1, SpoilerLeakDetail: `[{"src":"旧"}]`, }); err != nil { t.Fatalf("write the new retrieval-state columns after the upgrade: %v", err) } got, err := s.GetRetrievalState("b", 1, 0) if err != nil || got == nil || got.NVoiceFlags != 3 || got.NSpoilerLeaks != 1 || got.VoiceDetail == "" { t.Fatalf("the new columns did not round-trip after the upgrade: %v %+v", err, got) } } // The bank is replaced as ONE unit: a re-seed that drops a profile must drop it from the store too, or a // stale row would keep feeding the flagger a character the seed no longer signs. func TestReplaceBankIsAFullReplaceOfAllThreeRecordTypes(t *testing.T) { s, _ := openTemp(t) terms := []GlossaryEntry{{BookID: "b", Src: "方源", Dst: "Фан Юань", Status: "approved"}} if err := s.ReplaceBank("b", terms, []VoiceProfile{{Src: "方源", SelfRef: "первый"}, {Src: "白凝冰", SelfRef: "второй"}}, []AddressPair{{SpeakerSrc: "方源", AddresseeSrc: "白凝冰", Register: "formal"}}); err != nil { t.Fatal(err) } if v, _ := s.VoiceProfilesForBook("b"); len(v) != 2 { t.Fatalf("setup: want 2 profiles, got %d", len(v)) } // Re-seed with ONE profile and no pairs at all. if err := s.ReplaceBank("b", terms, []VoiceProfile{{Src: "方源", SelfRef: "первый"}}, nil); err != nil { t.Fatal(err) } v, err := s.VoiceProfilesForBook("b") if err != nil || len(v) != 1 || v[0].Src != "方源" { t.Fatalf("the dropped profile lingered: %v %+v", err, v) } if p, _ := s.AddressPairsForBook("b"); len(p) != 0 { t.Fatalf("the dropped address pairs lingered: %+v", p) } // Another book's content is untouched by a replace scoped to this one. if err := s.ReplaceBank("other", nil, []VoiceProfile{{Src: "X", SelfRef: "чужой"}}, nil); err != nil { t.Fatal(err) } if err := s.ReplaceBank("b", terms, nil, nil); err != nil { t.Fatal(err) } if o, _ := s.VoiceProfilesForBook("other"); len(o) != 1 { t.Fatalf("a replace must be scoped to its book, got %+v", o) } }