package store import ( "context" "database/sql" "path/filepath" "testing" ) func TestMigrationV6AddsUnsubscribeColumns(t *testing.T) { ctx := context.Background() path := filepath.Join(t.TempDir(), "old.db") // Simulate a v5 database: messages has no List-Unsubscribe columns and the // unsubscribe table has no url/updated_at. Opening it must apply migration // v6. db, err := sql.Open("sqlite", "file:"+path) if err != nil { t.Fatalf("open old db: %v", err) } stmts := []string{ `CREATE TABLE messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, gmail_id TEXT NOT NULL UNIQUE, thread_id TEXT, message_id_header TEXT, from_addr TEXT, to_addr TEXT, subject TEXT, body_text TEXT, source_address TEXT, date_sent TEXT, imap_uid INTEGER, imap_uidvalidity INTEGER, folder TEXT NOT NULL DEFAULT 'inbox', mailbox TEXT NOT NULL DEFAULT 'INBOX', received_at TEXT NOT NULL DEFAULT (datetime('now')), created_at TEXT NOT NULL DEFAULT (datetime('now')))`, `CREATE TABLE unsubscribe ( id INTEGER PRIMARY KEY AUTOINCREMENT, domain TEXT NOT NULL UNIQUE, status TEXT NOT NULL DEFAULT 'done' CHECK (status IN ('done', 'ignored')), created_at TEXT NOT NULL DEFAULT (datetime('now')))`, `CREATE TABLE schema_migrations (version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT (datetime('now')))`, `INSERT INTO schema_migrations (version) VALUES (5)`, } for _, q := range stmts { if _, err := db.ExecContext(ctx, q); err != nil { t.Fatalf("seed old db: %v", err) } } _ = db.Close() s, err := Open(ctx, path) if err != nil { t.Fatalf("Open() on v5 database error = %v", err) } defer s.Close() v, err := s.SchemaVersion(ctx) if err != nil { t.Fatalf("SchemaVersion() error = %v", err) } if v < 6 { t.Errorf("SchemaVersion() = %d, want >= 6", v) } for _, tc := range []struct{ table, col string }{ {"messages", "list_unsubscribe"}, {"messages", "list_unsubscribe_post"}, {"unsubscribe", "url"}, {"unsubscribe", "updated_at"}, } { var n int q := `SELECT COUNT(*) FROM pragma_table_info('` + tc.table + `') WHERE name = ?` if err := s.DB().QueryRowContext(ctx, q, tc.col).Scan(&n); err != nil { t.Fatalf("count %s.%s: %v", tc.table, tc.col, err) } if n != 1 { t.Errorf("%s.%s missing after migration v6", tc.table, tc.col) } } } func TestUnsubscribeRecordUpsert(t *testing.T) { ctx := context.Background() s := newCategoryStore(t) // reuses the store helper from categories_test.go if _, ok, err := s.UnsubscribeByDomain(ctx, "brand.example"); err != nil || ok { t.Fatalf("UnsubscribeByDomain() = (_, %v, %v), want a miss", ok, err) } if err := s.SetUnsubscribeStatus(ctx, "brand.example", UnsubscribeStatusIgnored, "https://brand.example/u"); err != nil { t.Fatalf("SetUnsubscribeStatus(ignore) error = %v", err) } rec, ok, err := s.UnsubscribeByDomain(ctx, "brand.example") if err != nil || !ok { t.Fatalf("UnsubscribeByDomain() = (_, %v, %v), want a hit", ok, err) } if rec.Status != UnsubscribeStatusIgnored || rec.URL != "https://brand.example/u" { t.Errorf("record = %+v, want ignored with url", rec) } // A later mark with no URL must keep the recorded link. if err := s.SetUnsubscribeStatus(ctx, "brand.example", UnsubscribeStatusDone, ""); err != nil { t.Fatalf("SetUnsubscribeStatus(done) error = %v", err) } rec, _, _ = s.UnsubscribeByDomain(ctx, "brand.example") if rec.Status != UnsubscribeStatusDone { t.Errorf("status = %q, want done", rec.Status) } if rec.URL != "https://brand.example/u" { t.Errorf("url = %q, want the earlier link preserved", rec.URL) } if err := s.SetUnsubscribeStatus(ctx, "brand.example", "bogus", ""); err == nil { t.Error("SetUnsubscribeStatus(bogus) accepted an unknown status") } records, err := s.ListUnsubscribe(ctx) if err != nil || len(records) != 1 { t.Fatalf("ListUnsubscribe() = (%v, %v), want one record", records, err) } if err := s.ClearUnsubscribe(ctx, "brand.example"); err != nil { t.Fatalf("ClearUnsubscribe() error = %v", err) } if err := s.SetUnsubscribeStatus(ctx, "", UnsubscribeStatusIgnored, ""); err == nil { t.Error("SetUnsubscribeStatus accepted an empty domain") } } func TestUnsubscribeCandidates(t *testing.T) { ctx := context.Background() s := newCategoryStore(t) seed := func(gmailID, from, body, header string) { t.Helper() if _, err := s.UpsertMessage(ctx, &Message{ GmailID: gmailID, FromAddr: from, Subject: "subject " + gmailID, BodyText: body, ListUnsubscribe: header, }); err != nil { t.Fatalf("seed %s: %v", gmailID, err) } } seed("header-msg", "Support ", "hello", "") seed("body-msg", "Shop ", "You can unsubscribe at https://shop.example/opt-out now.", "") seed("plain-msg", "Friend ", "Just saying hi.", "") got, err := s.UnsubscribeCandidates(ctx, 50) if err != nil { t.Fatalf("UnsubscribeCandidates() error = %v", err) } if len(got) != 2 { t.Fatalf("candidates = %d, want 2 (%+v)", len(got), got) } ids := map[string]bool{} for _, c := range got { ids[c.Subject] = true } if !ids["subject body-msg"] || !ids["subject header-msg"] { t.Errorf("candidates = %+v, want the header and body messages", ids) } } func TestRulesVersionsAndRestore(t *testing.T) { ctx := context.Background() s := newCategoryStore(t) if _, err := s.SaveRules(ctx, "from contains \"a\" => category=Info"); err != nil { t.Fatalf("SaveRules() error = %v", err) } if _, err := s.SaveRules(ctx, "from contains \"b\" => category=Action"); err != nil { t.Fatalf("SaveRules() error = %v", err) } versions, err := s.RulesVersions(ctx, 10) if err != nil { t.Fatalf("RulesVersions() error = %v", err) } if len(versions) < 3 { t.Fatalf("RulesVersions() = %d, want >= 3 (seed + 2 saves)", len(versions)) } if versions[0].Version != 3 { t.Errorf("newest version = %d, want 3", versions[0].Version) } text, ok, err := s.RuleByVersion(ctx, 2) if err != nil || !ok { t.Fatalf("RuleByVersion(2) = (_, %v, %v), want a hit", ok, err) } if text != "from contains \"a\" => category=Info" { t.Errorf("RuleByVersion(2) = %q", text) } if _, ok, _ := s.RuleByVersion(ctx, 999); ok { t.Error("RuleByVersion(999) reported a hit") } }