| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "bytes" |
| 5 | "html/template" |
| 6 | "io/fs" |
| 7 | "os" |
| 8 | "os/exec" |
| 9 | "path/filepath" |
| 10 | "regexp" |
| 11 | "slices" |
| 12 | "strings" |
| 13 | "testing" |
| 14 | |
| 15 | "amadan.net/rastrillo/rastrillo/ui" |
| 16 | |
| 17 | "amadan.net/rastrillo/abono" |
| 18 | ) |
| 19 | |
| 20 | // wantFiles is every file `abono pages` writes, relative to -dir. |
| 21 | var wantFiles = []string{ |
| 22 | "abono_pages.go", |
| 23 | "templates/abono/account.html", |
| 24 | "templates/abono/done.html", |
| 25 | "templates/abono/owner-end.html", |
| 26 | "templates/abono/owner-members.html", |
| 27 | "templates/abono/owner-offers.html", |
| 28 | "templates/abono/owner-stripe.html", |
| 29 | "templates/abono/plan.html", |
| 30 | "templates/abono/pricing.html", |
| 31 | } |
| 32 | |
| 33 | // cli runs the command in-process and returns its exit code and output. |
| 34 | func cli(args ...string) (int, string, string) { |
| 35 | var stdout, stderr bytes.Buffer |
| 36 | code := run(args, &stdout, &stderr) |
| 37 | return code, stdout.String(), stderr.String() |
| 38 | } |
| 39 | |
| 40 | // generate writes the pages into a fresh directory as package pkg. |
| 41 | func generate(t *testing.T, pkg string) string { |
| 42 | t.Helper() |
| 43 | dir := t.TempDir() |
| 44 | if code, _, stderr := cli("pages", "-pkg", pkg, "-dir", dir); code != 0 { |
| 45 | t.Fatalf("abono pages: exit %d: %s", code, stderr) |
| 46 | } |
| 47 | return dir |
| 48 | } |
| 49 | |
| 50 | // written lists the files under dir, slash-separated and sorted. |
| 51 | func written(t *testing.T, dir string) []string { |
| 52 | t.Helper() |
| 53 | var got []string |
| 54 | err := filepath.WalkDir(dir, func(p string, d fs.DirEntry, err error) error { |
| 55 | if err != nil || d.IsDir() { |
| 56 | return err |
| 57 | } |
| 58 | rel, _ := filepath.Rel(dir, p) |
| 59 | got = append(got, filepath.ToSlash(rel)) |
| 60 | return nil |
| 61 | }) |
| 62 | if err != nil { |
| 63 | t.Fatal(err) |
| 64 | } |
| 65 | slices.Sort(got) |
| 66 | return got |
| 67 | } |
| 68 | |
| 69 | func TestPagesWritesTheFilesAndRefusesToOverwrite(t *testing.T) { |
| 70 | dir := generate(t, "shop") |
| 71 | if got := written(t, dir); !slices.Equal(got, wantFiles) { |
| 72 | t.Fatalf("wrote %q, want %q", got, wantFiles) |
| 73 | } |
| 74 | src, err := os.ReadFile(filepath.Join(dir, "abono_pages.go")) |
| 75 | if err != nil { |
| 76 | t.Fatal(err) |
| 77 | } |
| 78 | if !bytes.Contains(src, []byte("\npackage shop\n")) { |
| 79 | t.Errorf("abono_pages.go does not say package shop:\n%s", src[:min(len(src), 400)]) |
| 80 | } |
| 81 | |
| 82 | // Run again over an edited page: refused, every existing file named, |
| 83 | // and nothing touched. |
| 84 | edited := filepath.Join(dir, "templates", "abono", "pricing.html") |
| 85 | if err := os.WriteFile(edited, []byte("mine"), 0o644); err != nil { |
| 86 | t.Fatal(err) |
| 87 | } |
| 88 | code, _, stderr := cli("pages", "-pkg", "shop", "-dir", dir) |
| 89 | if code == 0 { |
| 90 | t.Fatal("a second run succeeded over existing pages") |
| 91 | } |
| 92 | for _, f := range wantFiles { |
| 93 | if !strings.Contains(stderr, filepath.Join(dir, filepath.FromSlash(f))) { |
| 94 | t.Errorf("the refusal does not name %s:\n%s", f, stderr) |
| 95 | } |
| 96 | } |
| 97 | if b, _ := os.ReadFile(edited); string(b) != "mine" { |
| 98 | t.Errorf("an existing page was overwritten: %q", b) |
| 99 | } |
| 100 | |
| 101 | // One target present is enough to refuse, and nothing else is written. |
| 102 | dir2 := t.TempDir() |
| 103 | if err := os.WriteFile(filepath.Join(dir2, "abono_pages.go"), []byte("package x\n"), 0o644); err != nil { |
| 104 | t.Fatal(err) |
| 105 | } |
| 106 | code, _, stderr = cli("pages", "-pkg", "shop", "-dir", dir2) |
| 107 | if code == 0 || !strings.Contains(stderr, filepath.Join(dir2, "abono_pages.go")) { |
| 108 | t.Errorf("exit %d over one existing file; stderr:\n%s", code, stderr) |
| 109 | } |
| 110 | if got := written(t, dir2); !slices.Equal(got, []string{"abono_pages.go"}) { |
| 111 | t.Errorf("a refused run wrote %q", got) |
| 112 | } |
| 113 | |
| 114 | // There is no -force. |
| 115 | if code, _, _ := cli("pages", "-pkg", "shop", "-dir", dir, "-force"); code == 0 { |
| 116 | t.Error("-force was accepted") |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | func TestPagesRefusesBadArguments(t *testing.T) { |
| 121 | dir := t.TempDir() |
| 122 | for _, args := range [][]string{ |
| 123 | {}, |
| 124 | {"nonsense"}, |
| 125 | {"pages"}, |
| 126 | {"pages", "-dir", dir}, |
| 127 | {"pages", "-pkg", "shop"}, |
| 128 | {"pages", "-pkg", "9lives", "-dir", dir}, |
| 129 | {"pages", "-pkg", "func", "-dir", dir}, |
| 130 | {"pages", "-pkg", "shop", "-dir", dir, "extra"}, |
| 131 | } { |
| 132 | if code, _, stderr := cli(args...); code == 0 || stderr == "" { |
| 133 | t.Errorf("abono %q: exit %d, stderr %q; want a refusal", args, code, stderr) |
| 134 | } |
| 135 | } |
| 136 | if got := written(t, dir); len(got) != 0 { |
| 137 | t.Errorf("refused runs wrote %q", got) |
| 138 | } |
| 139 | } |
| 140 | |
| 141 | // TestGeneratedPagesMatchTheExample: the example's pages are this |
| 142 | // generator's output, byte for byte, so the example can never drift from |
| 143 | // what an app gets. |
| 144 | func TestGeneratedPagesMatchTheExample(t *testing.T) { |
| 145 | dir := generate(t, "main") |
| 146 | example := filepath.Join("..", "..", "example") |
| 147 | for _, f := range wantFiles { |
| 148 | want, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(f))) |
| 149 | if err != nil { |
| 150 | t.Fatal(err) |
| 151 | } |
| 152 | got, err := os.ReadFile(filepath.Join(example, filepath.FromSlash(f))) |
| 153 | if err != nil { |
| 154 | t.Errorf("example: %v", err) |
| 155 | continue |
| 156 | } |
| 157 | if !bytes.Equal(got, want) { |
| 158 | t.Errorf("example/%s differs from the generator's output; regenerate it", f) |
| 159 | } |
| 160 | } |
| 161 | var extra []string |
| 162 | for _, f := range written(t, filepath.Join(example, "templates", "abono")) { |
| 163 | if !slices.Contains(wantFiles, "templates/abono/"+f) { |
| 164 | extra = append(extra, f) |
| 165 | } |
| 166 | } |
| 167 | if len(extra) > 0 { |
| 168 | t.Errorf("example/templates/abono has files the generator does not write: %q", extra) |
| 169 | } |
| 170 | } |
| 171 | |
| 172 | // TestGeneratedTemplatesParse: every page parses beside ui's partials and |
| 173 | // abono's, as an app's ui.Pages parses them, and defines what its header |
| 174 | // says it does. |
| 175 | func TestGeneratedTemplatesParse(t *testing.T) { |
| 176 | dir := generate(t, "shop") |
| 177 | base, err := template.New("").Funcs(ui.Funcs()).ParseFS(ui.Templates(), "*.html") |
| 178 | if err != nil { |
| 179 | t.Fatal(err) |
| 180 | } |
| 181 | if base, err = base.ParseFS(abono.Templates(), "*.html"); err != nil { |
| 182 | t.Fatal(err) |
| 183 | } |
| 184 | for _, f := range wantFiles { |
| 185 | if !strings.HasSuffix(f, ".html") { |
| 186 | continue |
| 187 | } |
| 188 | c := template.Must(base.Clone()) |
| 189 | if _, err := c.ParseFiles(filepath.Join(dir, filepath.FromSlash(f))); err != nil { |
| 190 | t.Errorf("%s: %v", f, err) |
| 191 | continue |
| 192 | } |
| 193 | want := "content" |
| 194 | if strings.HasSuffix(f, "/account.html") { |
| 195 | want = "abono-account" |
| 196 | } |
| 197 | if c.Lookup(want) == nil { |
| 198 | t.Errorf("%s does not define %q", f, want) |
| 199 | } |
| 200 | } |
| 201 | } |
| 202 | |
| 203 | // TestGeneratedCodeBuilds: the generated Go compiles in an app module |
| 204 | // that imports chi, as rastrillo apps do, with a stub main beside it. |
| 205 | // The module borrows the example's go.mod and go.sum, so it builds |
| 206 | // against the versions the example pins. |
| 207 | func TestGeneratedCodeBuilds(t *testing.T) { |
| 208 | if testing.Short() { |
| 209 | t.Skip("builds a module") |
| 210 | } |
| 211 | dir := generate(t, "main") |
| 212 | root, err := filepath.Abs(filepath.Join("..", "..")) |
| 213 | if err != nil { |
| 214 | t.Fatal(err) |
| 215 | } |
| 216 | mod, err := os.ReadFile(filepath.Join(root, "example", "go.mod")) |
| 217 | if err != nil { |
| 218 | t.Fatal(err) |
| 219 | } |
| 220 | sum, err := os.ReadFile(filepath.Join(root, "example", "go.sum")) |
| 221 | if err != nil { |
| 222 | t.Fatal(err) |
| 223 | } |
| 224 | gomod := strings.Replace(string(mod), "=> ../", "=> "+filepath.ToSlash(root)+"/", 1) |
| 225 | if gomod == string(mod) { |
| 226 | t.Fatal("example/go.mod has no replace of abono to ../") |
| 227 | } |
| 228 | stub := `package main |
| 229 | |
| 230 | import "github.com/go-chi/chi/v5" |
| 231 | |
| 232 | func main() { |
| 233 | p := &AbonoPages{} |
| 234 | p.Mount(chi.NewRouter()) |
| 235 | } |
| 236 | ` |
| 237 | for name, body := range map[string]string{"go.mod": gomod, "go.sum": string(sum), "stub.go": stub} { |
| 238 | if err := os.WriteFile(filepath.Join(dir, name), []byte(body), 0o644); err != nil { |
| 239 | t.Fatal(err) |
| 240 | } |
| 241 | } |
| 242 | // -buildvcs=false: the temporary directory may sit inside someone |
| 243 | // else's repository. -mod=mod: the stub needs fewer modules than the |
| 244 | // example lists, which is not an error worth failing on. |
| 245 | cmd := exec.Command("go", "build", "-buildvcs=false", "-mod=mod", "-o", os.DevNull, ".") |
| 246 | cmd.Dir = dir |
| 247 | if out, err := cmd.CombinedOutput(); err != nil { |
| 248 | t.Fatalf("go build: %v\n%s", err, out) |
| 249 | } |
| 250 | cmd = exec.Command("go", "vet", "-mod=mod", ".") |
| 251 | cmd.Dir = dir |
| 252 | if out, err := cmd.CombinedOutput(); err != nil { |
| 253 | t.Fatalf("go vet: %v\n%s", err, out) |
| 254 | } |
| 255 | } |
| 256 | |
| 257 | // starterKeys are the message keys the starter pages may use: the draft |
| 258 | // copy's (ruling R22), every one of which they use, and two price |
| 259 | // sentences the partials own and the offers page repeats. |
| 260 | var starterKeys = func() []string { |
| 261 | var keys []string |
| 262 | for prefix, names := range map[string]string{ |
| 263 | "abono.button.": "see_plans go_to_account try_again sync give_free end_free end_free_confirm keep_free adopt", |
| 264 | "abono.pages.": "pricing_title pricing_intro closed_title closed_body plan_intro plan_back plan_signed_out " + |
| 265 | "done_title done_paid done_signin done_unpaid_title done_unpaid_body account_title account_none account_manage_help", |
| 266 | "abono.owner.": "members_title members_intro members_empty col_member col_plan col_status col_source " + |
| 267 | "source_paid source_free backstop sync_help sync_done sync_done_one free_title free_help free_member " + |
| 268 | "free_plan free_ends free_note free_ends_hint free_note_hint free_given end_title end_body end_done " + |
| 269 | "offers_title offers_intro offers_empty price_sold price_legacy unrecognised_title unrecognised_body " + |
| 270 | "unrecognised_warn unrecognised_none adopt_plan adopted stripe_title stripe_on stripe_test stripe_off " + |
| 271 | "stripe_off_help stripe_sandbox stripe_webhook stripe_webhook_help nothing_for_sale", |
| 272 | "abono.error.": "unknown_code unknown_plan no_billing signin_needed unreachable finish_failed not_available " + |
| 273 | "free_plan sync_failed adopt_failed", |
| 274 | } { |
| 275 | for _, n := range strings.Fields(names) { |
| 276 | keys = append(keys, prefix+n) |
| 277 | } |
| 278 | } |
| 279 | return keys |
| 280 | }() |
| 281 | |
| 282 | // partialKeys are keys the pages may name that the partials already use. |
| 283 | var partialKeys = []string{"abono.price.per_month", "abono.price.per_year"} |
| 284 | |
| 285 | // newKeys are keys the pages need that the draft lacks (ruling R28), |
| 286 | // going through their own copy review. The pages must use each. |
| 287 | var newKeys = []string{"abono.owner.free_no_member", "abono.pages.done_processing", "abono.owner.price_unsupported", |
| 288 | "abono.owner.add_price_title", "abono.owner.add_price_id", "abono.button.add_price", "abono.button.make_sold", |
| 289 | "abono.error.add_price_failed", "abono.error.sync_partial"} |
| 290 | |
| 291 | var keyLiteral = regexp.MustCompile(`"(abono\.[a-z][a-z_.]*[a-z])"`) |
| 292 | |
| 293 | // TestPagesUseTheDraftKeys: every word on the starter pages is a key of |
| 294 | // the draft copy or one of newKeys, and no draft key is left out. abono.button.subscribe |
| 295 | // and abono.button.manage are the partials' own, reached through them. |
| 296 | func TestPagesUseTheDraftKeys(t *testing.T) { |
| 297 | dir := generate(t, "shop") |
| 298 | used := map[string]bool{} |
| 299 | for _, f := range wantFiles { |
| 300 | src, err := os.ReadFile(filepath.Join(dir, filepath.FromSlash(f))) |
| 301 | if err != nil { |
| 302 | t.Fatal(err) |
| 303 | } |
| 304 | for _, m := range keyLiteral.FindAllStringSubmatch(string(src), -1) { |
| 305 | used[m[1]] = true |
| 306 | } |
| 307 | } |
| 308 | for k := range used { |
| 309 | if !slices.Contains(starterKeys, k) && !slices.Contains(partialKeys, k) && !slices.Contains(newKeys, k) { |
| 310 | t.Errorf("the pages use %s, which is not a draft key", k) |
| 311 | } |
| 312 | } |
| 313 | for _, k := range append(append([]string{}, starterKeys...), newKeys...) { |
| 314 | if !used[k] { |
| 315 | t.Errorf("the pages never use the draft key %s", k) |
| 316 | } |
| 317 | } |
| 318 | } |
| 319 | |