Large albums opened slowly and flickered; the default album cover was also lost in the media-objects refactor. Large-album loading: - quantize signed object URLs within a window and send Cache-Control so previews are reused instead of re-fetched - publish photo.updated (not photo.created) when variants are ready, so clients update in place without reordering the grid - worker concurrency + newest-first variant priority; raise default batch Album cover: - first uploaded photo becomes the cover when none is set - clear the cover when the cover photo is trashed or moved out - "set as album cover" in the photo context menu and actions panel OpenSpec changes: fix-large-album-load, fix-album-cover
151 lines
3.8 KiB
Go
151 lines
3.8 KiB
Go
package photos
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
|
|
"galo/internal/storage"
|
|
)
|
|
|
|
func TestWorkerGeneratesVariantsAndCollects(t *testing.T) {
|
|
pool := testPool(t)
|
|
svc, cl := newTestService(t, pool)
|
|
runWorkerFlow(t, pool, svc, cl)
|
|
}
|
|
|
|
func TestWorkerGeneratesVariantsWithFS(t *testing.T) {
|
|
pool := testPool(t)
|
|
svc, cl := newTestServiceFS(t, pool)
|
|
runWorkerFlow(t, pool, svc, cl)
|
|
}
|
|
|
|
func runWorkerFlow(t *testing.T, pool *pgxpool.Pool, svc *Service, cl storage.Backend) {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
user := createUser(t, ctx, pool)
|
|
album := createAlbum(t, ctx, pool, user)
|
|
|
|
p, err := svc.Upload(ctx, UploadInput{
|
|
AlbumID: album,
|
|
UserID: user,
|
|
Filename: "photo.png",
|
|
MimeType: "image/png",
|
|
Data: makePNG(t),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("upload: %v", err)
|
|
}
|
|
t.Cleanup(func() { cleanupPhoto(t, ctx, pool, cl, p.ID) })
|
|
|
|
sub := svc.hub.SubscribeAlbum(album)
|
|
defer sub.Close()
|
|
|
|
w := NewWorker(svc, time.Second, 10, time.Minute, 2)
|
|
w.processVariantJobs(ctx)
|
|
|
|
select {
|
|
case ev := <-sub.Events():
|
|
if ev.Type != "photo.updated" {
|
|
t.Fatalf("variant-ready event = %q, want photo.updated", ev.Type)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatalf("no variant-ready event published")
|
|
}
|
|
|
|
refreshed, err := svc.store.GetByID(ctx, p.ID, "")
|
|
if err != nil {
|
|
t.Fatalf("get refreshed: %v", err)
|
|
}
|
|
if refreshed.ThumbKey == "" || refreshed.PreviewKey == "" {
|
|
t.Fatalf("variants not bound: thumb=%q preview=%q", refreshed.ThumbKey, refreshed.PreviewKey)
|
|
}
|
|
if err := svc.attachURLs(ctx, refreshed); err != nil {
|
|
t.Fatalf("attach urls: %v", err)
|
|
}
|
|
if refreshed.ThumbURL == "" || refreshed.PreviewURL == "" {
|
|
t.Fatalf("variant urls not set")
|
|
}
|
|
|
|
var jobs int
|
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM variant_jobs WHERE photo_id = $1`, p.ID).Scan(&jobs); err != nil {
|
|
t.Fatalf("count jobs: %v", err)
|
|
}
|
|
if jobs != 0 {
|
|
t.Fatalf("variant job not completed: %d left", jobs)
|
|
}
|
|
|
|
objectIDs, err := svc.store.PhotoObjectIDs(ctx, p.ID)
|
|
if err != nil || len(objectIDs) != 3 {
|
|
t.Fatalf("expected 3 bound objects, got %v err=%v", objectIDs, err)
|
|
}
|
|
|
|
if err := svc.PermanentDelete(ctx, p.ID, user); err != nil {
|
|
t.Fatalf("permanent delete: %v", err)
|
|
}
|
|
// Run the collector until the objects are gone (bounded to avoid hangs).
|
|
for attempt := 0; attempt < 100 && countObjects(t, ctx, pool, objectIDs) > 0; attempt++ {
|
|
w.collectGarbage(ctx)
|
|
}
|
|
if n := countObjects(t, ctx, pool, objectIDs); n != 0 {
|
|
t.Fatalf("%d objects were not collected", n)
|
|
}
|
|
}
|
|
|
|
func TestWorkerConcurrencyPool(t *testing.T) {
|
|
w := &Worker{concurrency: 3}
|
|
ids := []string{"a", "b", "c", "d", "e"}
|
|
started := make(chan struct{}, len(ids))
|
|
release := make(chan struct{})
|
|
done := make(chan struct{})
|
|
go func() {
|
|
w.forEachConcurrent(ids, func(string) {
|
|
started <- struct{}{}
|
|
<-release
|
|
})
|
|
close(done)
|
|
}()
|
|
|
|
for i := 0; i < 3; i++ {
|
|
select {
|
|
case <-started:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("only %d tasks started concurrently, want 3", i)
|
|
}
|
|
}
|
|
select {
|
|
case <-started:
|
|
t.Fatalf("more than concurrency tasks started concurrently")
|
|
case <-time.After(50 * time.Millisecond):
|
|
}
|
|
|
|
close(release)
|
|
select {
|
|
case <-done:
|
|
case <-time.After(2 * time.Second):
|
|
t.Fatalf("pool did not finish")
|
|
}
|
|
}
|
|
|
|
func TestWorkerConcurrencySequentialAtOne(t *testing.T) {
|
|
w := &Worker{concurrency: 1}
|
|
var order []string
|
|
w.forEachConcurrent([]string{"a", "b", "c"}, func(id string) {
|
|
order = append(order, id)
|
|
})
|
|
if len(order) != 3 || order[0] != "a" || order[1] != "b" || order[2] != "c" {
|
|
t.Fatalf("sequential order = %v, want [a b c]", order)
|
|
}
|
|
}
|
|
|
|
func countObjects(t *testing.T, ctx context.Context, pool *pgxpool.Pool, ids []string) int {
|
|
t.Helper()
|
|
var n int
|
|
if err := pool.QueryRow(ctx, `SELECT count(*) FROM objects WHERE id = ANY($1)`, ids).Scan(&n); err != nil {
|
|
t.Fatalf("count objects: %v", err)
|
|
}
|
|
return n
|
|
}
|