Files
iu 50dff63ece feat: rename service to galo, add halo branding and release build
- rename Go module, binary, systemd unit, PG_DATABASE default, frontend package and docs from shared-album to galo
- add theme-aware halo emblem and favicon (new ui-branding capability)
- add make build-release (versioned, stripped, static binary) and run the binary from the repo root
2026-10-11 11:36:56 +03:00

188 lines
5.5 KiB
Go

package photos
import (
"context"
"errors"
"testing"
"time"
"github.com/jackc/pgx/v5/pgxpool"
"galo/internal/auth"
)
func TestObjectStateTransitions(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
st := NewStore(pool)
objID := auth.NewID()
obj := &Object{ID: objID, StorageKey: "objects/" + objID + "/original.jpg", MimeType: "image/jpeg", Size: 42}
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objID) })
if err := st.CreateObject(ctx, obj); err != nil {
t.Fatalf("create object: %v", err)
}
if obj.State != ObjectPending {
t.Fatalf("new object state = %q, want %q", obj.State, ObjectPending)
}
if obj.CreatedAt.IsZero() {
t.Fatalf("created_at not populated")
}
if err := st.ActivateObject(ctx, objID); err != nil {
t.Fatalf("activate: %v", err)
}
if got := objectState(t, ctx, pool, objID); got != ObjectReady {
t.Fatalf("state after activate = %q", got)
}
if err := st.MarkObjectDeleting(ctx, objID); err != nil {
t.Fatalf("mark deleting: %v", err)
}
if got := objectState(t, ctx, pool, objID); got != ObjectDeleting {
t.Fatalf("state after mark = %q", got)
}
if err := st.DeleteObjectRow(ctx, objID); err != nil {
t.Fatalf("delete row: %v", err)
}
if err := st.DeleteObjectRow(ctx, objID); !errors.Is(err, ErrObjectNotFound) {
t.Fatalf("second delete = %v, want ErrObjectNotFound", err)
}
}
func TestClaimStalePending(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
st := NewStore(pool)
objID := auth.NewID()
obj := &Object{ID: objID, StorageKey: "objects/" + objID + "/original.jpg"}
t.Cleanup(func() { _, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objID) })
if err := st.CreateObject(ctx, obj); err != nil {
t.Fatalf("create object: %v", err)
}
if _, err := pool.Exec(ctx, `UPDATE objects SET created_at = now() - interval '1 hour' WHERE id = $1`, objID); err != nil {
t.Fatalf("age object: %v", err)
}
objs, err := st.ClaimStalePending(ctx, 10*time.Minute, 100)
if err != nil {
t.Fatalf("claim stale: %v", err)
}
if !containsObject(objs, objID) {
t.Fatalf("stale pending object not claimed: %+v", objs)
}
if got := objectState(t, ctx, pool, objID); got != ObjectDeleting {
t.Fatalf("claimed stale object state = %q, want deleting", got)
}
}
func TestBindObjectRequiresReadyAndShares(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
st := NewStore(pool)
user := createUser(t, ctx, pool)
p1 := createPhoto(t, ctx, pool, user)
p2 := createPhoto(t, ctx, pool, user)
objID := auth.NewID()
obj := &Object{ID: objID, StorageKey: "objects/" + objID + "/original.jpg"}
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM photo_objects WHERE object_id = $1`, objID)
_, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id = $1`, objID)
})
if err := st.CreateObject(ctx, obj); err != nil {
t.Fatalf("create object: %v", err)
}
if err := st.BindObject(ctx, p1, objID, RoleOriginal); !errors.Is(err, ErrObjectNotReady) {
t.Fatalf("bind pending object = %v, want ErrObjectNotReady", err)
}
if err := st.ActivateObject(ctx, objID); err != nil {
t.Fatalf("activate: %v", err)
}
if err := st.BindObject(ctx, p1, objID, RoleOriginal); err != nil {
t.Fatalf("bind p1: %v", err)
}
if err := st.BindObject(ctx, p2, objID, RoleOriginal); err != nil {
t.Fatalf("bind p2: %v", err)
}
if n, err := st.ObjectRefCount(ctx, objID); err != nil || n != 2 {
t.Fatalf("ref count = %d err=%v, want 2", n, err)
}
siblings, err := st.PhotosByOriginalObject(ctx, objID)
if err != nil || len(siblings) != 2 {
t.Fatalf("siblings = %v err=%v, want 2", siblings, err)
}
}
func TestClaimGarbageRespectsReferences(t *testing.T) {
pool := testPool(t)
ctx := context.Background()
st := NewStore(pool)
user := createUser(t, ctx, pool)
photo := createPhoto(t, ctx, pool, user)
boundID := auth.NewID()
freeID := auth.NewID()
t.Cleanup(func() {
_, _ = pool.Exec(ctx, `DELETE FROM photo_objects WHERE object_id IN ($1, $2)`, boundID, freeID)
_, _ = pool.Exec(ctx, `DELETE FROM objects WHERE id IN ($1, $2)`, boundID, freeID)
})
for _, id := range []string{boundID, freeID} {
if err := st.CreateObject(ctx, &Object{ID: id, StorageKey: "objects/" + id + "/original.jpg"}); err != nil {
t.Fatalf("create object: %v", err)
}
if err := st.ActivateObject(ctx, id); err != nil {
t.Fatalf("activate: %v", err)
}
}
if err := st.BindObject(ctx, photo, boundID, RoleOriginal); err != nil {
t.Fatalf("bind: %v", err)
}
claimed, err := st.ClaimGarbage(ctx, 100)
if err != nil {
t.Fatalf("claim garbage: %v", err)
}
if !containsObject(claimed, freeID) {
t.Fatalf("unreferenced object not claimed: %+v", claimed)
}
if containsObject(claimed, boundID) {
t.Fatalf("referenced object was claimed for deletion")
}
// Removing the last reference makes both collectible.
if _, err := pool.Exec(ctx, `DELETE FROM photos WHERE id = $1`, photo); err != nil {
t.Fatalf("delete photo: %v", err)
}
claimed, err = st.ClaimGarbage(ctx, 100)
if err != nil {
t.Fatalf("claim garbage 2: %v", err)
}
if !containsObject(claimed, boundID) {
t.Fatalf("object with no remaining references not claimed: %+v", claimed)
}
}
func objectState(t *testing.T, ctx context.Context, pool *pgxpool.Pool, id string) string {
t.Helper()
var state string
if err := pool.QueryRow(ctx, `SELECT state FROM objects WHERE id = $1`, id).Scan(&state); err != nil {
t.Fatalf("read state: %v", err)
}
return state
}
func containsObject(objs []Object, id string) bool {
for _, o := range objs {
if o.ID == id {
return true
}
}
return false
}