package abono_test import ( "context" "path/filepath" "testing" "time" "amadan.net/rastrillo/rastrillo/db" "amadan.net/rastrillo/rastrillo/migrate" "amadan.net/rastrillo/rastrillo/sessions" "amadan.net/rastrillo/abono" ) // openDB is a fresh, on-disk (t.TempDir) rastrillo db: a real // database, not an in-memory stand-in. func openDB(t *testing.T) *db.DB { t.Helper() d, err := db.Open(filepath.Join(t.TempDir(), "abono.db"), nil) if err != nil { t.Fatalf("db.Open: %v", err) } t.Cleanup(func() { d.Close() }) return d } // TestSchemaAppliesAndReplays checks that merging abono.Schema in // after sessions.Schema, the documented BootSchema order, creates all // six tables, and that a second Apply is a no-op. func TestSchemaAppliesAndReplays(t *testing.T) { d := openDB(t) full := migrate.Merge(sessions.Schema, abono.Schema) first, err := migrate.Apply(context.Background(), d, full) if err != nil { t.Fatalf("first migrate.Apply: %v", err) } if len(first.Applied) == 0 { t.Fatal("first migrate.Apply applied nothing; a no-op second run could not be told from a broken first one") } second, err := migrate.Apply(context.Background(), d, full) if err != nil { t.Fatalf("second migrate.Apply: %v", err) } if len(second.Applied) != 0 { t.Errorf("second migrate.Apply applied %v, want none", second.Applied) } for _, name := range []string{ "abono_offers", "abono_prices", "abono_customers", "abono_subscriptions", "abono_comps", "abono_events", } { var got string err := d.Writer().QueryRow( `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, name, ).Scan(&got) if err != nil { t.Errorf("table %s missing: %v", name, err) } } } // TestSchemaTimestampsDecode writes a comp with an ends_at and reads // it back through gorm into a *time.Time. modernc.org/sqlite hands a // timestamp back as a time.Time only when the column's DECLARED type // is DATE, DATETIME or TIMESTAMP; against TEXT columns every gorm read // fails with "unsupported Scan, storing driver.Value type string into // type *time.Time". Reverting a column to TEXT must fail here, by name. func TestSchemaTimestampsDecode(t *testing.T) { d := openDB(t) if _, err := migrate.Apply(context.Background(), d, migrate.Merge(sessions.Schema, abono.Schema)); err != nil { t.Fatalf("migrate.Apply: %v", err) } now := time.Now().UTC().Truncate(time.Millisecond) ends := now.Add(48 * time.Hour) if _, err := d.Writer().Exec( `INSERT INTO abono_comps (subject, plan, status, ends_at, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?)`, "s1", "supporter", "active", ends, now, now, ); err != nil { t.Fatalf("inserting a comp: %v", err) } type comp struct { ID int64 Subject string EndsAt *time.Time CreatedAt time.Time } var got comp if err := d.G.Table("abono_comps").Where("subject = ?", "s1").Take(&got).Error; err != nil { t.Fatalf("reading the comp back: %v\n"+ "An \"unsupported Scan ... into type *time.Time\" here means a timestamp column "+ "is declared TEXT; it must be DATETIME.", err) } if got.EndsAt == nil || !got.EndsAt.Equal(ends) { t.Errorf("EndsAt = %v, want %v", got.EndsAt, ends) } if !got.CreatedAt.Equal(now) { t.Errorf("CreatedAt = %v, want %v", got.CreatedAt, now) } // A NULL ends_at (a comp with no end) must scan as a nil pointer. if _, err := d.Writer().Exec( `INSERT INTO abono_comps (subject, plan, status, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, "s2", "supporter", "active", now, now, ); err != nil { t.Fatalf("inserting an open-ended comp: %v", err) } var open comp if err := d.G.Table("abono_comps").Where("subject = ?", "s2").Take(&open).Error; err != nil { t.Fatalf("reading the open-ended comp back: %v", err) } if open.EndsAt != nil { t.Errorf("EndsAt = %v, want nil for a NULL timestamp", open.EndsAt) } } // frozenAbonoChecksum is migrate.Checksum of abono's shipped // migrations/0001_init.sql. // // ==> ONCE v0.1.0 IS TAGGED, THIS CONSTANT MAY NEVER BE UPDATED. <== // // It is recorded in the task that wrote the migration and may be // re-recorded only before v0.1.0 is tagged, while no app's ledger // holds this migration. From the first tag onwards the sentence above // is literal, and a failure here means an edit to revert, not a // constant to refresh. // // A failure here does not mean the constant is stale; it means an // edit to a shipped migration file changed its checksum, and that // edit must be reverted. Every app that has applied this migration has // this checksum in its ledger; migrate.Apply compares the two on every // boot and refuses to start if they no longer match. The only // legitimate way to change a shipped migration's effect is a new // migration file beside it, with a new ID and a new entry here. See // rastrillo's own migrate/frozen_checksums_test.go, which this test is // deliberately shaped after. const frozenAbonoChecksum = "906331b740701f831c91bb51cc414f1f43b66502b24599ac6c01da6eaef52858" func TestSchemaFrozenChecksum(t *testing.T) { for _, m := range abono.Schema.All() { if m.ID != "abono/0001_init" { t.Fatalf("unexpected migration id %q; if abono has grown a second migration, "+ "this test must be extended, not just re-pointed at the new one", m.ID) } if got := migrate.Checksum(m.SQL); got != frozenAbonoChecksum { t.Errorf("%s: checksum is now %s, was %s.\n"+ "A shipped migration file was edited. Revert the edit — do NOT update the "+ "constant. Every deployed app has the old checksum in its ledger and will "+ "refuse to boot with \"applied with different SQL\". To change what the schema "+ "becomes, add a new migration file instead.", m.ID, got, frozenAbonoChecksum) } } }