test(12.2-05): cover every Go behaviour of phase 12.2

- lagoon: Date and TimeOfDay through JSON, text and real DATE/TIME
  columns; Fill text fallback without changing earlier conversions;
  required on zero dates; deferred_bindings shape, store isolation and
  envelope; PurgeDeferred cut-off, after-commit blobs, SKIP LOCKED,
  skipped types and the deferred:purge command
- attach: Store limits, extensions, MIME patterns, default lists, key
  shape and blob cleanup; IsAllowedImage formats, polyglots, ceiling
- conga and pact: framework purge schedule entry and forged jobs; the
  six relation child hook interfaces
- cabana: fileupload and datepicker compile, upload, remove, caption,
  reorder and bounds; deferred commit order, rollback, applied-only and
  concurrent saves; relation contracts, forms, CRUD, deferral, schema
This commit is contained in:
Jakub Zych
2026-10-02 20:34:23 +02:00
parent 9c87a59532
commit c8d41a68c6
16 changed files with 3509 additions and 10 deletions

View File

@@ -2,11 +2,15 @@ package lagoon
import (
"bytes"
"database/sql"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net"
"strings"
"testing"
"time"
)
type fillFixture struct {
@@ -229,3 +233,145 @@ func TestFillTypeErrorNamesTheKey(t *testing.T) {
t.Fatalf("non-pointer model: err = %v, want a plain error", err)
}
}
// fillDual implements both sql.Scanner and encoding.TextUnmarshaler with
// parsers that disagree; Fill must keep filling it through Scan, as it did
// before the text fallback existed.
type fillDual struct{ via string }
func (d *fillDual) Scan(src any) error {
d.via = fmt.Sprintf("scan:%s", src)
return nil
}
func (d *fillDual) UnmarshalText(b []byte) error {
d.via = "text:" + string(b)
return nil
}
// fillTextOnly implements only encoding.TextUnmarshaler.
type fillTextOnly struct{ got string }
func (d *fillTextOnly) UnmarshalText(b []byte) error {
if string(b) == "bad" {
return errors.New("bad text")
}
d.got = string(b)
return nil
}
// TestFillTextDateTypes fills every date type and pointer variant from
// strings: RFC 3339 with an offset keeps the instant, an empty string gives
// the zero Date/TimeOfDay, and an unparsable string is a FillTypeError on
// its key.
func TestFillTextDateTypes(t *testing.T) {
allowed := []string{"at", "at_ptr", "day", "day_ptr", "clock", "clock_ptr"}
var m fillDates
err := Fill(&m, allowed, map[string]any{
"at": "2026-10-02T14:30:00+02:00",
"at_ptr": []byte("2026-10-02T12:30:00Z"),
"day": []byte("2026-10-02"),
"day_ptr": "2026-02-28",
"clock": "00:00",
"clock_ptr": "23:59:59",
}, true)
if err != nil {
t.Fatal(err)
}
instant := time.Date(2026, 10, 2, 12, 30, 0, 0, time.UTC)
if !m.At.Equal(instant) || m.AtPtr == nil || !m.AtPtr.Equal(instant) {
t.Fatalf("instants %v %v", m.At, m.AtPtr)
}
if _, offset := m.At.Zone(); offset != 2*3600 {
t.Fatalf("offset %d, want the request's +02:00 kept on the value", offset)
}
if m.Day.String() != "2026-10-02" || m.DayPtr.String() != "2026-02-28" || m.Clock.String() != "00:00:00" || m.Clock.IsZero() || m.ClkPtr.String() != "23:59:59" {
t.Fatalf("dates %+v", m)
}
if err := Fill(&m, allowed, map[string]any{"day": "", "clock": "", "day_ptr": nil, "clock_ptr": nil, "at_ptr": nil}, true); err != nil {
t.Fatal(err)
}
if !m.Day.IsZero() || !m.Clock.IsZero() || m.DayPtr != nil || m.ClkPtr != nil || m.AtPtr != nil {
t.Fatalf("cleared %+v", m)
}
for key, value := range map[string]any{
"at": "2026-10-02",
"at_ptr": "yesterday",
"day": "02/10/2026",
"day_ptr": "2026-13-01",
"clock": "25:00",
"clock_ptr": "noon",
} {
var row fillDates
err := Fill(&row, allowed, map[string]any{key: value}, true)
var typed *FillTypeError
if !errors.As(err, &typed) || typed.Key != key {
t.Fatalf("%s = %v: err = %v, want a FillTypeError on %s", key, value, err, key)
}
}
// A plain Date also takes the date part of a timestamp: after the text
// parser refuses it, Fill falls back to Date.Scan, which accepts the
// driver's "YYYY-MM-DDT..." text form.
var lenient fillDates
if err := Fill(&lenient, allowed, map[string]any{"day": "2026-10-02T23:30:00Z"}, true); err != nil || lenient.Day.String() != "2026-10-02" {
t.Fatalf("timestamp into Date = %v %v", lenient.Day, err)
}
}
// TestFillTextKeepsEarlierConversions pins conversions that worked before
// the text fallback: assignable values, convertible kinds, json.Number,
// Scanner-only types, and a type that implements both Scanner and
// TextUnmarshaler still filling through Scan.
func TestFillTextKeepsEarlierConversions(t *testing.T) {
type row struct {
Name string `gorm:"column:name"`
Bytes []byte `gorm:"column:bytes"`
Count int `gorm:"column:count"`
Null sql.NullString `gorm:"column:null"`
Dual fillDual `gorm:"column:dual"`
DualPtr *fillDual `gorm:"column:dual_ptr"`
IP net.IP `gorm:"column:ip"`
At time.Time `gorm:"column:at"`
TextOnly fillTextOnly `gorm:"column:text_only"`
}
allowed := []string{"name", "bytes", "count", "null", "dual", "dual_ptr", "ip", "at", "text_only"}
at := time.Date(2026, 10, 2, 8, 0, 0, 0, time.UTC)
var r row
err := Fill(&r, allowed, map[string]any{
"name": "plain",
"bytes": "raw",
"count": json.Number("7"),
"null": "scanned",
"dual": "x",
"ip": "10.0.0.1",
"at": at,
"text_only": "fallback",
}, true)
if err != nil {
t.Fatal(err)
}
if r.Name != "plain" || string(r.Bytes) != "raw" || r.Count != 7 || !r.Null.Valid || r.Null.String != "scanned" || !r.At.Equal(at) {
t.Fatalf("plain conversions %+v", r)
}
if r.Dual.via != "scan:x" {
t.Fatalf("Scanner+TextUnmarshaler filled through %q, want scan", r.Dual.via)
}
// A string converts to net.IP's []byte kind before any text parsing,
// exactly as before the fallback.
if string(r.IP) != "10.0.0.1" {
t.Fatalf("net.IP = %v", []byte(r.IP))
}
if r.TextOnly.got != "fallback" {
t.Fatalf("TextUnmarshaler-only type = %+v", r.TextOnly)
}
var typed *FillTypeError
if err := Fill(&r, allowed, map[string]any{"text_only": "bad"}, true); !errors.As(err, &typed) || typed.Key != "text_only" {
t.Fatalf("bad text = %v", err)
}
if err := Fill(&r, allowed, map[string]any{"text_only": 5}, true); !errors.As(err, &typed) {
t.Fatalf("a number into a text type = %v, want a FillTypeError", err)
}
}