Files
code-utils/festival_test.go
2026-08-15 17:18:00 +08:00

217 lines
7.4 KiB
Go

package main
// festival_test.go covers festival background image bindings:
// admin gate (cloud user id must be 1), save/list/remove round trip,
// display mode switching, legacy payload parsing, dirty flags for sync.
import (
"bytes"
"encoding/base64"
"image"
"image/color"
"image/png"
"strings"
"testing"
)
// festTestApp returns an app whose sync target points at an unreachable port,
// so the background sync kick started by save/remove fails fast and touches nothing.
func festTestApp(t *testing.T) *App {
a := newSyncTestApp(t)
_ = a.store.SetMeta("sync_base_url", "http://127.0.0.1:1")
return a
}
func festPngDataURL(t *testing.T, w, h int, c color.Color) string {
t.Helper()
img := image.NewRGBA(image.Rect(0, 0, w, h))
for x := 0; x < w; x++ {
for y := 0; y < h; y++ {
img.Set(x, y, c)
}
}
var buf bytes.Buffer
if e := png.Encode(&buf, img); e != nil {
t.Fatal(e)
}
return "data:image/png;base64," + base64.StdEncoding.EncodeToString(buf.Bytes())
}
func TestFestivalImageAdminGate(t *testing.T) {
a := festTestApp(t)
data := festPngDataURL(t, 8, 8, color.RGBA{200, 40, 40, 255})
// not logged in
if _, e := a.SetFestivalImageData("春节", data); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" {
t.Fatalf("expected admin-only error when logged out, got %v", e)
}
// logged in as a regular user
_ = a.store.SetMeta("sync_user_id", "2")
if _, e := a.SetFestivalImageData("春节", data); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" {
t.Fatalf("expected admin-only error for user 2, got %v", e)
}
if e := a.RemoveFestivalImage("春节"); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" {
t.Fatalf("expected admin-only error on remove, got %v", e)
}
if e := a.SetFestivalImageMode("春节", "art"); e == nil || e.Error() != "FESTIVAL_IMG_ADMIN_ONLY" {
t.Fatalf("expected admin-only error on mode switch, got %v", e)
}
}
func TestFestivalImageSaveListRemove(t *testing.T) {
a := festTestApp(t)
_ = a.store.SetMeta("sync_user_id", "1")
f, e := a.SetFestivalImageData("春节", festPngDataURL(t, 900, 300, color.RGBA{220, 30, 30, 255}))
if e != nil {
t.Fatal(e)
}
// uploading implies photo mode; opaque input re-encodes as jpeg below the edge cap
if f.Mode != "photo" || !strings.HasPrefix(f.Image, "data:image/jpeg;base64,") {
t.Fatalf("unexpected payload: mode=%q image=%.40s", f.Mode, f.Image)
}
if len(f.Image) > 200_000 {
t.Fatalf("encoded image unexpectedly large: %d", len(f.Image))
}
m, e := a.ListFestivalImages()
if e != nil {
t.Fatal(e)
}
if m["春节"].Image != f.Image || m["春节"].Mode != "photo" {
t.Fatalf("list mismatch: got %d entries", len(m))
}
var dirty int
if e := a.store.db.QueryRow(`SELECT dirty FROM festival_images WHERE fest_key='春节'`).Scan(&dirty); e != nil || dirty != 1 {
t.Fatalf("expected dirty=1 after save, got %d (%v)", dirty, e)
}
// second save overwrites the value
f2, e := a.SetFestivalImageData("春节", festPngDataURL(t, 100, 100, color.RGBA{20, 90, 220, 255}))
if e != nil {
t.Fatal(e)
}
if f2.Image == f.Image {
t.Fatal("expected a different data URL after overwrite")
}
if e := a.RemoveFestivalImage("春节"); e != nil {
t.Fatal(e)
}
m, _ = a.ListFestivalImages()
if len(m) != 0 {
t.Fatalf("expected empty list after remove, got %d", len(m))
}
// removal keeps the row as an empty-value tombstone marked dirty
var v string
if e := a.store.db.QueryRow(`SELECT value,dirty FROM festival_images WHERE fest_key='春节'`).Scan(&v, &dirty); e != nil || v != "" || dirty != 1 {
t.Fatalf("expected empty dirty tombstone, got value=%q dirty=%d (%v)", v, dirty, e)
}
}
func TestFestivalImageModeSwitch(t *testing.T) {
a := festTestApp(t)
_ = a.store.SetMeta("sync_user_id", "1")
if _, e := a.SetFestivalImageData("中秋", festPngDataURL(t, 50, 50, color.RGBA{230, 190, 90, 255})); e != nil {
t.Fatal(e)
}
var at1 string
_ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key='中秋'`).Scan(&at1)
if e := a.SetFestivalImageMode("中秋", "art"); e != nil {
t.Fatal(e)
}
m, _ := a.ListFestivalImages()
if m["中秋"].Mode != "art" || m["中秋"].Image == "" {
t.Fatalf("mode switch must keep image, got %+v", m["中秋"])
}
// stamp must strictly increase so the switch wins cloud-side LWW
var at2 string
var dirty int
_ = a.store.db.QueryRow(`SELECT updated_at,dirty FROM festival_images WHERE fest_key='中秋'`).Scan(&at2, &dirty)
if at2 <= at1 || dirty != 1 {
t.Fatalf("expected newer dirty stamp, at1=%q at2=%q dirty=%d", at1, at2, dirty)
}
// switching to the same mode is a no-op; unknown mode / missing row are rejected
if e := a.SetFestivalImageMode("中秋", "art"); e != nil {
t.Fatal(e)
}
if e := a.SetFestivalImageMode("中秋", "gif"); e == nil {
t.Fatal("expected error for bad mode")
}
if e := a.SetFestivalImageMode("不存在", "art"); e == nil || e.Error() != "FESTIVAL_IMG_NOT_FOUND" {
t.Fatalf("expected FESTIVAL_IMG_NOT_FOUND, got %v", e)
}
if e := a.SetFestivalImageMode("中秋", "photo"); e != nil {
t.Fatal(e)
}
m, _ = a.ListFestivalImages()
if m["中秋"].Mode != "photo" {
t.Fatalf("expected photo mode, got %+v", m["中秋"])
}
}
// Rows written by the previous version store the raw data URL; they must be
// read back as photo mode so existing cloud data keeps working.
func TestFestivalImageLegacyPayload(t *testing.T) {
a := festTestApp(t)
_ = a.store.SetMeta("sync_user_id", "1")
raw := festPngDataURL(t, 4, 4, color.RGBA{1, 2, 3, 255})
if _, e := a.store.db.Exec(`INSERT INTO festival_images(fest_key,value,updated_at,dirty) VALUES('元旦',?,?,0)`, raw, nowRFC()); e != nil {
t.Fatal(e)
}
m, e := a.ListFestivalImages()
if e != nil {
t.Fatal(e)
}
if m["元旦"].Mode != "photo" || m["元旦"].Image != raw {
t.Fatalf("legacy payload mismatch: %+v", m["元旦"])
}
// mode switch upgrades the row to the JSON payload
if e := a.SetFestivalImageMode("元旦", "art"); e != nil {
t.Fatal(e)
}
var v string
_ = a.store.db.QueryRow(`SELECT value FROM festival_images WHERE fest_key='元旦'`).Scan(&v)
if !strings.HasPrefix(v, "{") || !strings.Contains(v, `"mode":"art"`) {
t.Fatalf("row not upgraded to JSON: %.60s", v)
}
}
// Save and remove within the same second must still produce strictly increasing
// timestamps, otherwise the cloud-side strict LWW comparison rejects the second write.
func TestFestivalImageStampMonotonic(t *testing.T) {
a := festTestApp(t)
_ = a.store.SetMeta("sync_user_id", "1")
if _, e := a.SetFestivalImageData("端午", festPngDataURL(t, 6, 6, color.RGBA{10, 160, 90, 255})); e != nil {
t.Fatal(e)
}
var at1 string
_ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key='端午'`).Scan(&at1)
if e := a.RemoveFestivalImage("端午"); e != nil {
t.Fatal(e)
}
var at2 string
_ = a.store.db.QueryRow(`SELECT updated_at FROM festival_images WHERE fest_key='端午'`).Scan(&at2)
if at2 <= at1 {
t.Fatalf("tombstone stamp %q must be greater than save stamp %q", at2, at1)
}
}
func TestFestivalImageBadInput(t *testing.T) {
a := festTestApp(t)
_ = a.store.SetMeta("sync_user_id", "1")
if _, e := a.SetFestivalImageData("", festPngDataURL(t, 4, 4, color.White)); e == nil {
t.Fatal("expected error for empty key")
}
if _, e := a.SetFestivalImageData("春节", "data:image/png;base64,!!!"); e == nil {
t.Fatal("expected error for invalid base64 payload")
}
if _, e := a.SetFestivalImageData("春节", "hello"); e == nil {
t.Fatal("expected error for non-dataURL input")
}
}