rastrillo / abono Public

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

Plain git — no account needed to clone.

Download

Download this file

1package main
2
3import (
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.
21var 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.
34func 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.
41func 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.
51func 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
69func 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
120func 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.
144func 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.
175func 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.
207func 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
230import "github.com/go-chi/chi/v5"
231
232func 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.
260var 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.
283var 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.
287var 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
291var 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.
296func 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