220 lines
7.5 KiB
Go
220 lines
7.5 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_host", "127.0.0.1")
|
|
_ = a.store.SetMeta("sync_port", "1")
|
|
_ = a.store.SetMeta("sync_user", "x")
|
|
_ = a.store.SetMeta("sync_database", "nope")
|
|
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")
|
|
}
|
|
}
|