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") } }