test(13-06): cover the Phase 13 framework edges in surf, conga, lagoon and tide
- surf: a transitive three-route family, HEAD and sorted Allow on family paths, a family across two plugins with per-member middleware, refused trailing-slash and multi-segment shapes - conga: refusal messages name kind and queue, a configured queue counts as served, a delayed unregistered dispatch waits scheduled - lagoon: prohibited under a wildcard, on a dotted path and after bail - tide: quoted, RFC 5987 and multi-date download names; a captured id beside a masked notification id
This commit is contained in:
@@ -3,6 +3,7 @@ package conga
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -217,3 +218,45 @@ func TestUnregisteredKindWithoutWorker(t *testing.T) {
|
||||
t.Fatalf("river job = %+v, want available on default", row)
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnregisteredKindRefusalAndDelay pins the edges of the workerless
|
||||
// path: the refusal names the kind and the queue (a configured queue is
|
||||
// served too, and nothing is written), and a delayed Dispatch of an
|
||||
// unregistered kind waits scheduled with its summer_jobs row.
|
||||
func TestUnregisteredKindRefusalAndDelay(t *testing.T) {
|
||||
db, dsn := migratedDB(t)
|
||||
app, gdb := testApp(t, db, dsn, map[string]any{"queue.queues": map[string]any{"acme_imports": 2}})
|
||||
m, err := From(app)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
startTestWorker(t, app, WorkerOptions{pollOnly: true, pollInterval: unregisteredPoll},
|
||||
Job(func(context.Context, acmeMailArgs) error { return nil }, OnQueue("acme_mail")))
|
||||
ctx := t.Context()
|
||||
jobs, records := countKind(t, gdb, "acme.pending_import"), countRecords(t, gdb)
|
||||
|
||||
_, err = m.Dispatch(ctx, gdb, acmePendingImportArgs{ImportID: 1}, DispatchOpts{Label: "acme.pending", Queue: "acme_imports"})
|
||||
if !errors.Is(err, ErrUnregisteredKindQueue) || !strings.Contains(err.Error(), `kind "acme.pending_import" on queue "acme_imports", which a worker serves`) {
|
||||
t.Fatalf("configured queue: err = %v", err)
|
||||
}
|
||||
err = m.Enqueue(ctx, gdb, acmePendingImportArgs{ImportID: 1}, EnqueueOpts{})
|
||||
if !errors.Is(err, ErrUnregisteredKindQueue) || !strings.Contains(err.Error(), `kind "acme.pending_import" names no queue`) {
|
||||
t.Fatalf("no queue: err = %v", err)
|
||||
}
|
||||
if countKind(t, gdb, "acme.pending_import") != jobs || countRecords(t, gdb) != records {
|
||||
t.Fatal("a refused insert wrote a row")
|
||||
}
|
||||
|
||||
id, err := m.Dispatch(ctx, gdb, acmePendingImportArgs{ImportID: 2}, DispatchOpts{Label: "acme.later", Queue: "acme_later", Delay: time.Hour})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rec := mustGet(t, m, id)
|
||||
if rec.Label != "acme.later" || rec.RiverJobID == nil {
|
||||
t.Fatalf("record = %+v", rec)
|
||||
}
|
||||
row := riverJob(t, gdb, *rec.RiverJobID)
|
||||
if row.State != "scheduled" || row.Queue != "acme_later" || row.Attempt != 0 {
|
||||
t.Fatalf("delayed unregistered job = %+v, want scheduled on acme_later", row)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -713,3 +713,43 @@ func TestValidateRequestProhibited(t *testing.T) {
|
||||
t.Errorf("mixed = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateRequestProhibitedNested: prohibited under a wildcard names
|
||||
// each offending element, passes on elements that leave the key empty, and
|
||||
// works on a dotted path into a nested object; a bail before it stops at
|
||||
// the first failure as for any rule.
|
||||
func TestValidateRequestProhibitedNested(t *testing.T) {
|
||||
rules := []RequestRule{{Field: "albums.*.shelf", Rules: ParseRules("prohibited")}}
|
||||
input := map[string]any{"albums": []any{
|
||||
map[string]any{"name": "a", "shelf": "A1"},
|
||||
map[string]any{"name": "b"},
|
||||
map[string]any{"name": "c", "shelf": ""},
|
||||
map[string]any{"name": "d", "shelf": []any{"x"}},
|
||||
}}
|
||||
got := mustValidate(t, inLocale("en"), input, rules)
|
||||
want := map[string][]string{
|
||||
"albums.0.shelf": {"validation.prohibited"},
|
||||
"albums.3.shelf": {"validation.prohibited"},
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("wildcard = %#v, want %#v", got, want)
|
||||
}
|
||||
if got := mustValidate(t, inLocale("en"), map[string]any{"albums": []any{}}, rules); len(got) != 0 {
|
||||
t.Errorf("empty list = %#v, want none", got)
|
||||
}
|
||||
|
||||
dotted := []RequestRule{{Field: "meta.condition", Rules: ParseRules("prohibited")}}
|
||||
if got := mustValidate(t, inLocale("pl"), map[string]any{"meta": map[string]any{"condition": nil}}, dotted); len(got) != 0 {
|
||||
t.Errorf("dotted null = %#v, want none", got)
|
||||
}
|
||||
got = mustValidate(t, inLocale("pl"), map[string]any{"meta": map[string]any{"condition": "VG+"}}, dotted)
|
||||
if want := map[string][]string{"meta.condition": {"validation.prohibited"}}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("dotted = %#v, want %#v", got, want)
|
||||
}
|
||||
|
||||
bailed := []RequestRule{{Field: "shelf", Rules: ParseRules("bail|prohibited|string|max:2")}}
|
||||
got = mustValidate(t, inLocale("en"), map[string]any{"shelf": "ABC"}, bailed)
|
||||
if want := map[string][]string{"shelf": {"validation.prohibited"}}; !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("bail = %#v, want %#v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
148
modules/surf/overlap_edges_test.go
Normal file
148
modules/surf/overlap_edges_test.go
Normal file
@@ -0,0 +1,148 @@
|
||||
package surf
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// rackTable is a three-member family joined transitively: token/{token}
|
||||
// conflicts with {rackId}/items, which conflicts with items/{id}, while
|
||||
// token/{token} and items/{id} differ in a literal and never conflict.
|
||||
var rackTable = []overlapRoute{
|
||||
{name: "rack-token", method: "GET", path: "/racks/token/{token}"},
|
||||
{name: "rack-items", method: "GET", path: "/racks/{rackId}/items", where: map[string]string{"rackId": "[0-9]+"}},
|
||||
{name: "rack-items-delete", method: "DELETE", path: "/racks/{rackId}/items", where: map[string]string{"rackId": "[0-9]+"}},
|
||||
{name: "item-show", method: "GET", path: "/racks/items/{id}", where: map[string]string{"id": "[0-9]+"}},
|
||||
{name: "item-put", method: "PUT", path: "/racks/items/{id}", where: map[string]string{"id": "[0-9]+"}},
|
||||
{name: "rack-create", method: "POST", path: "/racks/{kind}/new"},
|
||||
}
|
||||
|
||||
func TestOverlapFamilyOfThree(t *testing.T) {
|
||||
// The conflict closure holds the three GET routes; the other methods
|
||||
// join the family at compile time through its generated pattern.
|
||||
r := buildOverlapRouter(t, rackTable)
|
||||
fams, err := overlapFamilies(r.routes)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(fams) != 1 || fams[0].pattern != "/racks/{surfOverlap1}/{surfOverlap2}" {
|
||||
t.Fatalf("families = %+v, want one family on one pattern", fams)
|
||||
}
|
||||
var members []string
|
||||
for _, idx := range fams[0].members {
|
||||
members = append(members, r.routes[idx].method+" "+r.routes[idx].path)
|
||||
}
|
||||
if strings.Join(members, ",") != "GET /racks/token/{token},GET /racks/{rackId}/items,GET /racks/items/{id}" {
|
||||
t.Fatalf("family members = %v", members)
|
||||
}
|
||||
h := compileOverlap(t, rackTable)
|
||||
for _, c := range []struct{ method, path, want string }{
|
||||
{"GET", "/racks/token/abc", "rack-token token=abc"},
|
||||
{"GET", "/racks/token/7", "rack-token token=7"},
|
||||
{"GET", "/racks/7/items", "rack-items rackId=7"},
|
||||
{"DELETE", "/racks/7/items", "rack-items-delete rackId=7"},
|
||||
{"GET", "/racks/items/9", "item-show id=9"},
|
||||
{"PUT", "/racks/items/9", "item-put id=9"},
|
||||
{"POST", "/racks/steel/new", "rack-create kind=steel"},
|
||||
} {
|
||||
rec := serve(h, c.method, c.path)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != c.want {
|
||||
t.Errorf("%s %s = %d %q, want 200 %q", c.method, c.path, rec.Code, rec.Body.String(), c.want)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/racks/items/items", "/racks/x7/items", "/racks/items/9x"} {
|
||||
if rec := serve(h, "GET", path); rec.Code != http.StatusNotFound || rec.Body.String() != "404 page not found\n" {
|
||||
t.Errorf("GET %s = %d %q, want the bare 404", path, rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlapHeadAndAllow(t *testing.T) {
|
||||
h := compileOverlap(t, rackTable)
|
||||
// A GET member answers HEAD, with its own handler.
|
||||
for _, path := range []string{"/racks/token/abc", "/racks/7/items", "/racks/items/9"} {
|
||||
if rec := serve(h, "HEAD", path); rec.Code != http.StatusOK {
|
||||
t.Errorf("HEAD %s = %d, want 200", path, rec.Code)
|
||||
}
|
||||
}
|
||||
// Allow lists the methods of every route that matches the path, sorted,
|
||||
// with HEAD beside GET, as ServeMux does for unrelated routes.
|
||||
for _, c := range []struct{ method, path, allow string }{
|
||||
{"POST", "/racks/items/9", "GET, HEAD, PUT"},
|
||||
{"PATCH", "/racks/7/items", "DELETE, GET, HEAD"},
|
||||
{"PUT", "/racks/token/abc", "GET, HEAD"},
|
||||
{"GET", "/racks/steel/new", "POST"},
|
||||
} {
|
||||
rec := serve(h, c.method, c.path)
|
||||
if rec.Code != http.StatusMethodNotAllowed || rec.Header().Get("Allow") != c.allow {
|
||||
t.Errorf("%s %s = %d Allow %q, want 405 %q", c.method, c.path, rec.Code, rec.Header().Get("Allow"), c.allow)
|
||||
}
|
||||
}
|
||||
// A method a member serves, whose constraint fails, is the bare 404,
|
||||
// never a 405 naming the member.
|
||||
if rec := serve(h, "DELETE", "/racks/abc/items"); rec.Code != http.StatusNotFound || rec.Header().Get("Allow") != "" {
|
||||
t.Errorf("DELETE /racks/abc/items = %d Allow %q, want the bare 404", rec.Code, rec.Header().Get("Allow"))
|
||||
}
|
||||
// A path no member and no route matches stays the router's 404.
|
||||
if rec := serve(h, "GET", "/racks/a/b/c"); rec.Code != http.StatusNotFound {
|
||||
t.Errorf("GET /racks/a/b/c = %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlapFamilyAcrossPlugins(t *testing.T) {
|
||||
r := New(nil)
|
||||
register := func(plugin string, rt overlapRoute) {
|
||||
r.BindPlugin(plugin)
|
||||
mw := "mw." + rt.name
|
||||
if err := r.RegisterMiddleware(plugin, mw, markMiddleware(rt.name)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
r.Get(rt.path, overlapHandler(rt), mw)
|
||||
for p, re := range rt.where {
|
||||
r.Where(p, re)
|
||||
}
|
||||
}
|
||||
register("acme.alpha", overlapRoute{name: "alpha-token", path: "/bins/token/{token}"})
|
||||
register("acme.beta", overlapRoute{name: "beta-items", path: "/bins/{binId}/items", where: map[string]string{"binId": "[0-9]+"}})
|
||||
h, err := r.compile()
|
||||
if err != nil {
|
||||
t.Fatalf("a family across plugins must compile: %v", err)
|
||||
}
|
||||
for _, c := range []struct{ path, body, mw string }{
|
||||
{"/bins/token/q", "alpha-token token=q", "alpha-token"},
|
||||
{"/bins/3/items", "beta-items binId=3", "beta-items"},
|
||||
} {
|
||||
rec := serve(h, "GET", c.path)
|
||||
if rec.Code != http.StatusOK || rec.Body.String() != c.body || strings.Join(rec.Header().Values("X-Mw"), ",") != c.mw {
|
||||
t.Errorf("GET %s = %d %q mw %v, want %q with only %s's middleware", c.path, rec.Code, rec.Body.String(), rec.Header().Values("X-Mw"), c.body, c.mw)
|
||||
}
|
||||
}
|
||||
plugins := map[string]string{}
|
||||
for _, info := range r.Routes() {
|
||||
plugins[info.Pattern] = info.PluginID
|
||||
}
|
||||
if plugins["/bins/token/{token}"] != "acme.alpha" || plugins["/bins/{binId}/items"] != "acme.beta" {
|
||||
t.Fatalf("route table plugins = %v", plugins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlapUnsupportedShapes(t *testing.T) {
|
||||
for name, table := range map[string][]overlapRoute{
|
||||
"trailing-slash": {
|
||||
{name: "dir", method: "GET", path: "/bins/token/{token}/"},
|
||||
{name: "items", method: "GET", path: "/bins/{binId}/items/", where: map[string]string{"binId": "[0-9]+"}},
|
||||
},
|
||||
"multi-segment": {
|
||||
{name: "rest", method: "GET", path: "/bins/token/{rest...}"},
|
||||
{name: "items", method: "GET", path: "/bins/{binId}/items", where: map[string]string{"binId": "[0-9]+"}},
|
||||
},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
_, err := buildOverlapRouter(t, table).compile()
|
||||
if err == nil || !strings.Contains(err.Error(), "route conflict") || !strings.Contains(err.Error(), `plugin "acme.shelves"`) {
|
||||
t.Fatalf("err = %v, want a route conflict naming both routes and their plugin", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -122,3 +122,50 @@ func TestNormalizeNotificationPublication(t *testing.T) {
|
||||
t.Fatalf("album publication\n got %s\nwant %s", got[0].Body, want)
|
||||
}
|
||||
}
|
||||
|
||||
// TestNormalizePhase13Edges: the download-date mask also holds for a
|
||||
// quoted filename, an RFC 5987 filename* and three dates in one name,
|
||||
// while a changed count of dates diffs; a notification publication keeps
|
||||
// a captured id beside its masked own id.
|
||||
func TestNormalizePhase13Edges(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
name, recorded, got string
|
||||
diff bool
|
||||
}{
|
||||
{"quoted", `attachment; filename="export-2026-09-17.csv"`, `attachment; filename="export-2026-10-03.csv"`, false},
|
||||
{"quoted stem change", `attachment; filename="export-2026-09-17.csv"`, `attachment; filename="backup-2026-10-03.csv"`, true},
|
||||
{"rfc5987", `attachment; filename*=UTF-8''p%C5%82yty-2026-09-17.csv`, `attachment; filename*=UTF-8''p%C5%82yty-2026-10-03.csv`, false},
|
||||
{"three dates", "attachment; filename=a-2026-01-01-2026-02-01-2026-03-01.csv", "attachment; filename=a-2027-01-01-2027-02-01-2027-03-01.csv", false},
|
||||
{"one date fewer", "attachment; filename=a-2026-01-01-2026-02-01.csv", "attachment; filename=a-2026-01-01.csv", true},
|
||||
{"leap day", "attachment; filename=a-2028-02-29.csv", "attachment; filename=a-2026-10-03.csv", false},
|
||||
{"not a leap year", "attachment; filename=a-2026-02-29.csv", "attachment; filename=a-2026-10-03.csv", true},
|
||||
} {
|
||||
diffs := compareHeaders(map[string]string{"Content-Disposition": c.recorded}, map[string]string{"Content-Disposition": c.got}, nil)
|
||||
if (len(diffs) > 0) != c.diff {
|
||||
t.Errorf("%s: diffs = %+v, want diff=%v", c.name, diffs, c.diff)
|
||||
}
|
||||
}
|
||||
|
||||
store := mustMemoryStore()
|
||||
store.Set("id:album", "5")
|
||||
pub := func(id, albumID string) []Publication {
|
||||
return []Publication{{Method: "POST", Path: "/api/publish", Body: json.RawMessage(
|
||||
`{"channel":"acme#9","data":{"event":"notification:new","payload":{"id":` + id +
|
||||
`,"type":"item_added","payload":{"album_id":` + albumID + `},"read_at":null,"created_at":"2026-10-03T08:00:00+00:00"}}}`)}}
|
||||
}
|
||||
got, err := NormalizePublications(pub("31", "5"), store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := string(got[0].Body)
|
||||
if !strings.Contains(body, `"payload":{"id":{{id}},`) || !strings.Contains(body, `"album_id":{{id:album}}`) {
|
||||
t.Fatalf("masks beside a captured id: %s", body)
|
||||
}
|
||||
other, err := NormalizePublications(pub("32", "6"), store)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if diffs := DiffPublications(got, other); len(diffs) == 0 {
|
||||
t.Fatal("a different album id must still diff")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user