From eb8a0280ca4d9fd9e1709cda37941942a5d9486a Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 19:54:54 +0100 Subject: [PATCH] test: unit tests for content facts, field type request options, variant keys and WebP - pact: the content kinds and tools, zero facts, the value types - cabana: mergeRequestOptions precedence, one call per type, fail-open, no shared maps - attach: VariantKey on thumbnail names - attach/webp: every image kind, the stored content type, the broken-image thumbnail and a refused mode --- modules/cabana/field_request_options_test.go | 92 +++++++++++++++ modules/lagoon/attach/variant_test.go | 51 ++++++++ modules/lagoon/attach/webp/webp_test.go | 115 +++++++++++++++++++ modules/pact/content_test.go | 101 ++++++++++++++++ 4 files changed, 359 insertions(+) create mode 100644 modules/cabana/field_request_options_test.go create mode 100644 modules/lagoon/attach/variant_test.go create mode 100644 modules/lagoon/attach/webp/webp_test.go create mode 100644 modules/pact/content_test.go diff --git a/modules/cabana/field_request_options_test.go b/modules/cabana/field_request_options_test.go new file mode 100644 index 00000000..dc513256 --- /dev/null +++ b/modules/cabana/field_request_options_test.go @@ -0,0 +1,92 @@ +package cabana + +import ( + "context" + "errors" + "reflect" + "testing" + + "git.golem15.com/golem15/summercms/modules/pact" + "gorm.io/gorm" +) + +// TestMergeRequestOptions pins mergeRequestOptions without a database: a +// key set in fields.yaml wins over the request's answer, the other answer +// keys join; each type is asked once however many fields use it; a field of +// a built-in type, a type without RequestOptions and an empty answer keep +// their options; an error fails open (the field keeps its fields.yaml +// options); the compiled options are never changed and fields never share +// a map. +func TestMergeRequestOptions(t *testing.T) { + calls := 0 + shared := map[string]any{"sources": []any{"a", "b"}, "mode": "request"} + withOptions := &compiledFieldType{owner: "acme.blog", def: pact.FormFieldType{Name: "picker", RequestOptions: func(context.Context, *gorm.DB) (map[string]any, error) { + calls++ + return shared, nil + }}} + failing := &compiledFieldType{owner: "acme.blog", def: pact.FormFieldType{Name: "broken", RequestOptions: func(context.Context, *gorm.DB) (map[string]any, error) { + return nil, errors.New("database is down") + }}} + empty := &compiledFieldType{owner: "acme.blog", def: pact.FormFieldType{Name: "empty", RequestOptions: func(context.Context, *gorm.DB) (map[string]any, error) { + return map[string]any{}, nil + }}} + plain := &compiledFieldType{owner: "acme.blog", def: pact.FormFieldType{Name: "plain"}} + + compiled := map[string]any{"mode": "yaml", "size": 3} + fields := []FormField{ + {Name: "one", fieldType: withOptions, TypeOptions: compiled}, + {Name: "two", fieldType: withOptions}, + {Name: "three", fieldType: failing, TypeOptions: map[string]any{"mode": "yaml"}}, + {Name: "four", fieldType: empty, TypeOptions: map[string]any{"size": 1}}, + {Name: "five", fieldType: plain, TypeOptions: map[string]any{"size": 2}}, + {Name: "six", TypeOptions: nil}, + } + mergeRequestOptions(context.Background(), nil, fields) + + if calls != 1 { + t.Fatalf("RequestOptions asked %d times, want once per type", calls) + } + if want := map[string]any{"sources": []any{"a", "b"}, "mode": "yaml", "size": 3}; !reflect.DeepEqual(fields[0].TypeOptions, want) { + t.Fatalf("field one = %v, want %v (fields.yaml wins)", fields[0].TypeOptions, want) + } + if want := map[string]any{"sources": []any{"a", "b"}, "mode": "request"}; !reflect.DeepEqual(fields[1].TypeOptions, want) { + t.Fatalf("field two = %v, want the answer", fields[1].TypeOptions) + } + if want := map[string]any{"mode": "yaml"}; !reflect.DeepEqual(fields[2].TypeOptions, want) { + t.Fatalf("field three = %v, want its fields.yaml options after the error", fields[2].TypeOptions) + } + if want := map[string]any{"size": 1}; !reflect.DeepEqual(fields[3].TypeOptions, want) { + t.Fatalf("field four = %v", fields[3].TypeOptions) + } + if want := map[string]any{"size": 2}; !reflect.DeepEqual(fields[4].TypeOptions, want) { + t.Fatalf("field five = %v", fields[4].TypeOptions) + } + if fields[5].TypeOptions != nil { + t.Fatalf("a built-in field got options %v", fields[5].TypeOptions) + } + if want := map[string]any{"mode": "yaml", "size": 3}; !reflect.DeepEqual(compiled, want) { + t.Fatalf("the compiled options changed: %v", compiled) + } + // The two fields of one type hold their own maps and copies of the + // answer's values. + fields[1].TypeOptions["mode"] = "changed" + fields[1].TypeOptions["sources"].([]any)[0] = "z" + if fields[0].TypeOptions["mode"] != "yaml" || shared["mode"] != "request" || shared["sources"].([]any)[0] != "a" { + t.Fatalf("fields share state: one=%v answer=%v", fields[0].TypeOptions, shared) + } +} + +// TestMergeRequestOptionsNoFields pins the empty request: nothing is asked. +func TestMergeRequestOptionsNoFields(t *testing.T) { + asked := false + ft := &compiledFieldType{def: pact.FormFieldType{Name: "picker", RequestOptions: func(context.Context, *gorm.DB) (map[string]any, error) { + asked = true + return nil, nil + }}} + _ = ft + mergeRequestOptions(context.Background(), nil, nil) + mergeRequestOptions(context.Background(), nil, []FormField{}) + if asked { + t.Fatal("RequestOptions was asked without a field") + } +} diff --git a/modules/lagoon/attach/variant_test.go b/modules/lagoon/attach/variant_test.go new file mode 100644 index 00000000..bae6d05b --- /dev/null +++ b/modules/lagoon/attach/variant_test.go @@ -0,0 +1,51 @@ +package attach + +import ( + "strings" + "testing" +) + +// TestVariantKeyTable pins VariantKey beside ThumbKey's names: the +// extension is replaced (case-insensitively, the format lower-cased), a key +// with the format's extension gets it appended, a key without an extension +// gets one, only the last extension counts, and a format outside the token +// rule or an empty key answers "". A variant never equals its thumbnail. +func TestVariantKeyTable(t *testing.T) { + for _, tc := range []struct{ thumb, format, want string }{ + {"a/b/c/thumb_1_480_0_0_0_auto.jpg", "webp", "a/b/c/thumb_1_480_0_0_0_auto.webp"}, + {"a/b/c/thumb_1_480_0_0_0_auto.JPG", "webp", "a/b/c/thumb_1_480_0_0_0_auto.webp"}, + {"a/b/c/thumb_1_480_0_0_0_auto.jpeg", "AVIF", "a/b/c/thumb_1_480_0_0_0_auto.avif"}, + {"a/b/c/thumb_1_480_0_0_0_auto.WEBP", "webp", "a/b/c/thumb_1_480_0_0_0_auto.WEBP.webp"}, + {"a/b/c/thumb_1_480_0_0_0_auto.tar.gz", "webp", "a/b/c/thumb_1_480_0_0_0_auto.tar.webp"}, + {"a/b/c/thumb_1_480_0_0_0_auto", "webp", "a/b/c/thumb_1_480_0_0_0_auto.webp"}, + {"a/b.c/thumb", "webp", "a/b.c/thumb.webp"}, + {"a/b/c/thumb_1.jpg", "web p", ""}, + {"a/b/c/thumb_1.jpg", "webp/", ""}, + {"a/b/c/thumb_1.jpg", "-", ""}, + {"", "jpg", ""}, + } { + got := VariantKey(tc.thumb, tc.format) + if got != tc.want { + t.Errorf("VariantKey(%q, %q) = %q, want %q", tc.thumb, tc.format, got, tc.want) + } + if got != "" && got == tc.thumb { + t.Errorf("VariantKey(%q, %q) overwrites the thumbnail", tc.thumb, tc.format) + } + } +} + +// TestVariantKeyOfThumbFilename pins the variant of a real thumbnail name +// (ThumbFilename in the original's partition directory): it keeps the +// directory and the stem and differs only in the extension, for an +// original of each common format. +func TestVariantKeyOfThumbFilename(t *testing.T) { + for _, ext := range []string{"jpg", "png", "gif", "webp"} { + disk := "abc123def456ghi789jkl0." + ext + thumb := PartitionDirectory(disk) + ThumbFilename(42, 480, 320, 0, 0, "exact", ext) + got := VariantKey(thumb, "webp") + stem := strings.TrimSuffix(thumb, "."+ext) + if got == thumb || !strings.HasPrefix(got, stem+".") || !strings.HasSuffix(got, ".webp") || strings.Contains(strings.TrimPrefix(got, stem), "/") { + t.Errorf("%s: VariantKey(%q) = %q", disk, thumb, got) + } + } +} diff --git a/modules/lagoon/attach/webp/webp_test.go b/modules/lagoon/attach/webp/webp_test.go new file mode 100644 index 00000000..5b9b96d6 --- /dev/null +++ b/modules/lagoon/attach/webp/webp_test.go @@ -0,0 +1,115 @@ +package webp_test + +import ( + "bytes" + "errors" + "image" + "image/color" + "image/draw" + "io" + "strings" + "testing" + + "git.golem15.com/golem15/summercms/modules/lagoon/attach" + "git.golem15.com/golem15/summercms/modules/lagoon/attach/webp" + "gocloud.dev/blob/memblob" +) + +// TestEncodeImageKinds pins Encode on the image kinds a decoder hands it: +// RGBA, NRGBA, grey, paletted and YCbCr pictures all come out as a WebP of +// the same size, and a 1x1 picture too. +func TestEncodeImageKinds(t *testing.T) { + rgba := gradient(48, 30) + nrgba := image.NewNRGBA(rgba.Bounds()) + draw.Draw(nrgba, nrgba.Bounds(), rgba, image.Point{}, draw.Src) + gray := image.NewGray(rgba.Bounds()) + draw.Draw(gray, gray.Bounds(), rgba, image.Point{}, draw.Src) + pal := image.NewPaletted(rgba.Bounds(), color.Palette{color.Black, color.White, color.RGBA{200, 20, 20, 255}}) + draw.Draw(pal, pal.Bounds(), rgba, image.Point{}, draw.Src) + ycc := image.NewYCbCr(rgba.Bounds(), image.YCbCrSubsampleRatio420) + one := image.NewRGBA(image.Rect(0, 0, 1, 1)) + for name, img := range map[string]image.Image{"rgba": rgba, "nrgba": nrgba, "gray": gray, "paletted": pal, "ycbcr": ycc, "1x1": one} { + var buf bytes.Buffer + if err := webp.Encode(&buf, img); err != nil { + t.Fatalf("%s: %v", name, err) + } + cfg, format, err := image.DecodeConfig(bytes.NewReader(buf.Bytes())) + b := img.Bounds() + if err != nil || format != "webp" || cfg.Width != b.Dx() || cfg.Height != b.Dy() || !isWebP(buf.Bytes()) { + t.Fatalf("%s: decoded %+v %q err=%v, want a %dx%d webp", name, cfg, format, err, b.Dx(), b.Dy()) + } + } +} + +// TestEncodeConstants pins the variant's format, content type and quality. +func TestEncodeConstants(t *testing.T) { + if webp.Format != "webp" || webp.ContentType != "image/webp" || webp.Quality < 1 || webp.Quality > 100 { + t.Fatalf("Format %q ContentType %q Quality %d", webp.Format, webp.ContentType, webp.Quality) + } +} + +// TestVariantStoredWithContentType pins the stored variant's content type +// and that the variant key is the thumbnail's VariantKey, and that a +// second call answers the same key without touching the stored bytes. +func TestVariantStoredWithContentType(t *testing.T) { + bucket := memblob.OpenBucket(nil) + defer bucket.Close() + f := &attach.File{ID: 11, DiskName: "jkl012mno345pqr678stu9.jpg"} + putOriginal(t, bucket, f, 320, 200) + key, err := webp.Variant(t.Context(), bucket, f, 160, 100, "exact") + if err != nil { + t.Fatal(err) + } + thumb, err := f.ThumbKey(t.Context(), bucket, 160, 100, "exact") + if err != nil || key != attach.VariantKey(thumb, webp.Format) { + t.Fatalf("key %q, thumbnail %q err=%v", key, thumb, err) + } + attrs, err := bucket.Attributes(t.Context(), key) + if err != nil || attrs.ContentType != webp.ContentType { + t.Fatalf("attributes %+v err=%v, want image/webp", attrs, err) + } + before, _ := bucket.ReadAll(t.Context(), key) + again, err := webp.Variant(t.Context(), bucket, f, 160, 100, "exact") + after, _ := bucket.ReadAll(t.Context(), key) + if err != nil || again != key || !bytes.Equal(before, after) { + t.Fatalf("second call %q err=%v, bytes changed %v", again, err, !bytes.Equal(before, after)) + } +} + +// TestVariantBrokenAndRefused pins the edges: a missing original gets +// ThumbKey's broken-image thumbnail, whose variant is still a WebP; a mode +// outside the token rule is refused and writes no variant. +func TestVariantBrokenAndRefused(t *testing.T) { + bucket := memblob.OpenBucket(nil) + defer bucket.Close() + missing := &attach.File{ID: 12, DiskName: "vwx901yza234bcd567efg8.jpg"} + key, err := webp.Variant(t.Context(), bucket, missing, 100, 100, "exact") + if err != nil { + t.Fatal(err) + } + if raw, err := bucket.ReadAll(t.Context(), key); err != nil || !isWebP(raw) { + t.Fatalf("variant of the broken-image thumbnail %q: err=%v", key, err) + } + good := &attach.File{ID: 14, DiskName: "tuv345wxy678zab901cde2.jpg"} + putOriginal(t, bucket, good, 200, 200) + if _, err := webp.Variant(t.Context(), bucket, good, 100, 100, "../x"); err == nil { + t.Fatal("a mode outside the token rule must fail") + } + iter := bucket.List(nil) + webps := 0 + for { + obj, err := iter.Next(t.Context()) + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatal(err) + } + if strings.HasSuffix(obj.Key, ".webp") { + webps++ + } + } + if webps != 1 { + t.Fatalf("%d WebP files in the bucket, want only the broken-image variant", webps) + } +} diff --git a/modules/pact/content_test.go b/modules/pact/content_test.go new file mode 100644 index 00000000..201a9444 --- /dev/null +++ b/modules/pact/content_test.go @@ -0,0 +1,101 @@ +package pact + +import ( + "encoding/json" + "reflect" + "testing" + "time" +) + +// TestContentKindAndToolValues pins the wire values of the content kinds +// and tools: plugins store and compare them as strings, so a value never +// changes once released, and each is distinct and lower-case. +func TestContentKindAndToolValues(t *testing.T) { + kinds := map[string]string{ + ContentKindArticle: "article", + ContentKindCollection: "collection", + ContentKindProfile: "profile", + ContentKindPage: "page", + } + tools := map[string]string{ + ContentToolEdit: "edit", + ContentToolHeadings: "headings", + ContentToolImageAlt: "image_alt", + } + for _, set := range []map[string]string{kinds, tools} { + seen := map[string]bool{} + for got, want := range set { + if got != want { + t.Errorf("constant = %q, want %q", got, want) + } + if seen[got] { + t.Errorf("%q is used twice", got) + } + seen[got] = true + } + } + if len(kinds) != 4 || len(tools) != 3 { + t.Fatalf("%d kinds and %d tools, want 4 and 3 distinct values", len(kinds), len(tools)) + } +} + +// TestContentRecordZeroFacts pins the additive contract: a record and a +// locale built without the new facts carry their zero values (no date, no +// parents or authors, TopHeading 0 for "not known", no images, videos or +// headings), and a descriptor without Kind or Tools says nothing. +func TestContentRecordZeroFacts(t *testing.T) { + rec := ContentRecord{ID: "1", Published: true, Locales: []ContentLocale{{Locale: "en", URL: "/a", Title: "A"}}} + if rec.PublishedAt != nil || rec.Parents != nil || rec.Authors != nil || rec.TopHeading != 0 { + t.Fatalf("record facts = %+v, want zero", rec) + } + l := rec.Locales[0] + if l.Images != nil || l.Videos != nil || l.Headings != nil { + t.Fatalf("locale facts = %+v, want zero", l) + } + var d ContentDescriptor + if d.Kind != "" || d.Tools != nil { + t.Fatalf("descriptor = %+v, want no kind and no tools", d) + } +} + +// TestContentValueTypes pins the value types' fields: a record with every +// fact set round-trips through a copy, and the value types are plain data +// (comparable where they hold no slice), so plugins can key maps by a ref. +func TestContentValueTypes(t *testing.T) { + at := time.Date(2026, 11, 4, 10, 0, 0, 0, time.UTC) + rec := ContentRecord{ + ID: "7", + Published: true, + PublishedAt: &at, + Parents: []ContentRef{{Type: "acme-category", ID: "3"}}, + Authors: []ContentPerson{{Name: "Ada", Ref: ContentRef{Type: "acme-author", ID: "1"}}, {Name: "Guest"}}, + TopHeading: 2, + Locales: []ContentLocale{{ + Locale: "en", + URL: "/posts/x", + Images: []ContentImage{{URL: "/m/a.jpg", Alt: "A cap", Width: 800, Height: 600}, {URL: "/m/line.png", Decorative: true}}, + Videos: []ContentVideo{{Provider: "youtube", ID: "abc"}}, + Headings: []ContentHeading{{Level: 2, Text: "Intro"}, {Level: 3, Text: "Detail"}}, + }}, + } + raw, err := json.Marshal(rec) + if err != nil { + t.Fatal(err) + } + var back ContentRecord + if err := json.Unmarshal(raw, &back); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(back.Parents, rec.Parents) || !reflect.DeepEqual(back.Authors, rec.Authors) || back.TopHeading != 2 || + !back.PublishedAt.Equal(at) || !reflect.DeepEqual(back.Locales[0].Images, rec.Locales[0].Images) || + !reflect.DeepEqual(back.Locales[0].Videos, rec.Locales[0].Videos) || !reflect.DeepEqual(back.Locales[0].Headings, rec.Locales[0].Headings) { + t.Fatalf("round trip = %+v, want %+v", back, rec) + } + refs := map[ContentRef]bool{{Type: "acme-category", ID: "3"}: true} + if !refs[rec.Parents[0]] || refs[rec.Authors[0].Ref] { + t.Fatal("ContentRef must work as a map key") + } + if (ContentHeading{Level: 2, Text: "Intro"}) != rec.Locales[0].Headings[0] || (ContentVideo{Provider: "youtube", ID: "abc"}) != rec.Locales[0].Videos[0] { + t.Fatal("headings and videos must compare by value") + } +}