rastrillo / abono Public

Clone
git clone https://amadan.net/rastrillo/abono

Plain git — no account needed to clone.

Download

Download this file

1package abono_test
2
3import (
4 "context"
5 "path/filepath"
6 "testing"
7 "time"
8
9 "amadan.net/rastrillo/rastrillo/db"
10 "amadan.net/rastrillo/rastrillo/migrate"
11 "amadan.net/rastrillo/rastrillo/sessions"
12
13 "amadan.net/rastrillo/abono"
14)
15
16// openDB is a fresh, on-disk (t.TempDir) rastrillo db: a real
17// database, not an in-memory stand-in.
18func openDB(t *testing.T) *db.DB {
19 t.Helper()
20 d, err := db.Open(filepath.Join(t.TempDir(), "abono.db"), nil)
21 if err != nil {
22 t.Fatalf("db.Open: %v", err)
23 }
24 t.Cleanup(func() { d.Close() })
25 return d
26}
27
28// TestSchemaAppliesAndReplays checks that merging abono.Schema in
29// after sessions.Schema, the documented BootSchema order, creates all
30// six tables, and that a second Apply is a no-op.
31func TestSchemaAppliesAndReplays(t *testing.T) {
32 d := openDB(t)
33 full := migrate.Merge(sessions.Schema, abono.Schema)
34
35 first, err := migrate.Apply(context.Background(), d, full)
36 if err != nil {
37 t.Fatalf("first migrate.Apply: %v", err)
38 }
39 if len(first.Applied) == 0 {
40 t.Fatal("first migrate.Apply applied nothing; a no-op second run could not be told from a broken first one")
41 }
42 second, err := migrate.Apply(context.Background(), d, full)
43 if err != nil {
44 t.Fatalf("second migrate.Apply: %v", err)
45 }
46 if len(second.Applied) != 0 {
47 t.Errorf("second migrate.Apply applied %v, want none", second.Applied)
48 }
49
50 for _, name := range []string{
51 "abono_offers", "abono_prices", "abono_customers",
52 "abono_subscriptions", "abono_comps", "abono_events",
53 } {
54 var got string
55 err := d.Writer().QueryRow(
56 `SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?`, name,
57 ).Scan(&got)
58 if err != nil {
59 t.Errorf("table %s missing: %v", name, err)
60 }
61 }
62}
63
64// TestSchemaTimestampsDecode writes a comp with an ends_at and reads
65// it back through gorm into a *time.Time. modernc.org/sqlite hands a
66// timestamp back as a time.Time only when the column's DECLARED type
67// is DATE, DATETIME or TIMESTAMP; against TEXT columns every gorm read
68// fails with "unsupported Scan, storing driver.Value type string into
69// type *time.Time". Reverting a column to TEXT must fail here, by name.
70func TestSchemaTimestampsDecode(t *testing.T) {
71 d := openDB(t)
72 if _, err := migrate.Apply(context.Background(), d,
73 migrate.Merge(sessions.Schema, abono.Schema)); err != nil {
74 t.Fatalf("migrate.Apply: %v", err)
75 }
76
77 now := time.Now().UTC().Truncate(time.Millisecond)
78 ends := now.Add(48 * time.Hour)
79 if _, err := d.Writer().Exec(
80 `INSERT INTO abono_comps (subject, plan, status, ends_at, created_at, updated_at)
81 VALUES (?, ?, ?, ?, ?, ?)`,
82 "s1", "supporter", "active", ends, now, now,
83 ); err != nil {
84 t.Fatalf("inserting a comp: %v", err)
85 }
86
87 type comp struct {
88 ID int64
89 Subject string
90 EndsAt *time.Time
91 CreatedAt time.Time
92 }
93 var got comp
94 if err := d.G.Table("abono_comps").Where("subject = ?", "s1").Take(&got).Error; err != nil {
95 t.Fatalf("reading the comp back: %v\n"+
96 "An \"unsupported Scan ... into type *time.Time\" here means a timestamp column "+
97 "is declared TEXT; it must be DATETIME.", err)
98 }
99 if got.EndsAt == nil || !got.EndsAt.Equal(ends) {
100 t.Errorf("EndsAt = %v, want %v", got.EndsAt, ends)
101 }
102 if !got.CreatedAt.Equal(now) {
103 t.Errorf("CreatedAt = %v, want %v", got.CreatedAt, now)
104 }
105
106 // A NULL ends_at (a comp with no end) must scan as a nil pointer.
107 if _, err := d.Writer().Exec(
108 `INSERT INTO abono_comps (subject, plan, status, created_at, updated_at)
109 VALUES (?, ?, ?, ?, ?)`,
110 "s2", "supporter", "active", now, now,
111 ); err != nil {
112 t.Fatalf("inserting an open-ended comp: %v", err)
113 }
114 var open comp
115 if err := d.G.Table("abono_comps").Where("subject = ?", "s2").Take(&open).Error; err != nil {
116 t.Fatalf("reading the open-ended comp back: %v", err)
117 }
118 if open.EndsAt != nil {
119 t.Errorf("EndsAt = %v, want nil for a NULL timestamp", open.EndsAt)
120 }
121}
122
123// frozenAbonoChecksum is migrate.Checksum of abono's shipped
124// migrations/0001_init.sql.
125//
126// ==> ONCE v0.1.0 IS TAGGED, THIS CONSTANT MAY NEVER BE UPDATED. <==
127//
128// It is recorded in the task that wrote the migration and may be
129// re-recorded only before v0.1.0 is tagged, while no app's ledger
130// holds this migration. From the first tag onwards the sentence above
131// is literal, and a failure here means an edit to revert, not a
132// constant to refresh.
133//
134// A failure here does not mean the constant is stale; it means an
135// edit to a shipped migration file changed its checksum, and that
136// edit must be reverted. Every app that has applied this migration has
137// this checksum in its ledger; migrate.Apply compares the two on every
138// boot and refuses to start if they no longer match. The only
139// legitimate way to change a shipped migration's effect is a new
140// migration file beside it, with a new ID and a new entry here. See
141// rastrillo's own migrate/frozen_checksums_test.go, which this test is
142// deliberately shaped after.
143const frozenAbonoChecksum = "906331b740701f831c91bb51cc414f1f43b66502b24599ac6c01da6eaef52858"
144
145func TestSchemaFrozenChecksum(t *testing.T) {
146 for _, m := range abono.Schema.All() {
147 if m.ID != "abono/0001_init" {
148 t.Fatalf("unexpected migration id %q; if abono has grown a second migration, "+
149 "this test must be extended, not just re-pointed at the new one", m.ID)
150 }
151 if got := migrate.Checksum(m.SQL); got != frozenAbonoChecksum {
152 t.Errorf("%s: checksum is now %s, was %s.\n"+
153 "A shipped migration file was edited. Revert the edit — do NOT update the "+
154 "constant. Every deployed app has the old checksum in its ledger and will "+
155 "refuse to boot with \"applied with different SQL\". To change what the schema "+
156 "becomes, add a new migration file instead.", m.ID, got, frozenAbonoChecksum)
157 }
158 }
159}
160