package photos import ( "bytes" "context" "image" "image/color" "image/png" "os" "path/filepath" "testing" "galo/internal/config" "galo/internal/models" "galo/internal/video" ) // fakeStore implements the slice of Repository used by generateVideo. type fakeStore struct { Repository orig *Object siblings []string objects map[string]*Object bindings map[string]string completed bool failmsgs []string meta [3]int64 } func newFakeStore(orig *Object, siblings []string) *fakeStore { return &fakeStore{orig: orig, siblings: siblings, objects: map[string]*Object{}, bindings: map[string]string{}} } func (s *fakeStore) OriginalObject(context.Context, string) (*Object, error) { return s.orig, nil } func (s *fakeStore) PhotosByOriginalObject(context.Context, string) ([]string, error) { return s.siblings, nil } func (s *fakeStore) CreateObject(_ context.Context, o *Object) error { s.objects[o.ID] = o; return nil } func (s *fakeStore) ActivateObject(context.Context, string) error { return nil } func (s *fakeStore) DeleteObjectRow(context.Context, string) error { return nil } func (s *fakeStore) MarkObjectDeleting(context.Context, string) error { return nil } func (s *fakeStore) BindObject(_ context.Context, _, objectID, role string) error { s.bindings[role] = objectID return nil } func (s *fakeStore) CompleteVariantJob(context.Context, string) error { s.completed = true; return nil } func (s *fakeStore) FailVariantJob(_ context.Context, _, msg string, _ int) error { s.failmsgs = append(s.failmsgs, msg) return nil } func (s *fakeStore) SetMediaMeta(_ context.Context, _, _ string, durationMS int64, width, height int) error { s.meta = [3]int64{durationMS, int64(width), int64(height)} return nil } func (s *fakeStore) GetByID(context.Context, string, string) (*models.Photo, error) { return nil, ErrNotFound } // writeFramePNG writes a small valid PNG the fake ffmpeg copies as its output. func writeFramePNG(t *testing.T) string { t.Helper() img := image.NewRGBA(image.Rect(0, 0, 8, 8)) for x := 0; x < 8; x++ { for y := 0; y < 8; y++ { img.Set(x, y, color.RGBA{R: uint8(x * 20), G: uint8(y * 20), B: 200, A: 255}) } } var buf bytes.Buffer if err := png.Encode(&buf, img); err != nil { t.Fatalf("encode png: %v", err) } path := filepath.Join(t.TempDir(), "frame.png") if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { t.Fatalf("write frame: %v", err) } return path } func writeScript(t *testing.T, name, body string) string { t.Helper() path := filepath.Join(t.TempDir(), name) if err := os.WriteFile(path, []byte(body), 0o755); err != nil { t.Fatalf("write %s: %v", name, err) } return path } func TestGenerateVideoHappyPath(t *testing.T) { frame := writeFramePNG(t) t.Setenv("FAKE_FRAME", frame) // A fake ffmpeg copies the frame to its output path (the last argument); // a fake ffprobe reports a web-compatible h264/aac source. ffmpeg := writeScript(t, "ffmpeg", "#!/bin/sh\nout=\"\"\nfor a in \"$@\"; do out=\"$a\"; done\ncp \"$FAKE_FRAME\" \"$out\"\n") ffprobe := writeScript(t, "ffprobe", "#!/bin/sh\ncat <<'JSON'\n{\"streams\":[{\"codec_type\":\"video\",\"codec_name\":\"h264\",\"profile\":\"High\",\"pix_fmt\":\"yuv420p\",\"width\":1280,\"height\":720},{\"codec_type\":\"audio\",\"codec_name\":\"aac\"}],\"format\":{\"format_name\":\"mov,mp4,m4a,3gp,3g2,mj2\",\"duration\":\"5.5\"}}\nJSON\n") tools := video.Detect(config.Video{FFmpegPath: ffmpeg, FFprobePath: ffprobe}) if !tools.Available() { t.Fatalf("fake toolchain not detected: %+v", tools) } orig := &Object{ID: "obj1", StorageKey: "objects/obj1/original.mp4", State: ObjectReady} store := newFakeStore(orig, []string{"photo1"}) svc := &Service{store: store, storage: fakeBackend{}} w := NewWorker(svc, 0, 0, 0, 1) w.SetVideo(tools, 1) w.generateVideo(context.Background(), "photo1") if len(store.failmsgs) != 0 { t.Fatalf("unexpected failures: %v", store.failmsgs) } if !store.completed { t.Fatalf("job not completed") } for _, role := range []string{RoleThumb, RoleMedium, RolePreview, RolePlayback} { if store.bindings[role] == "" { t.Fatalf("role %q not bound; bindings=%v", role, store.bindings) } } if store.objects[store.bindings[RolePlayback]].MimeType != "video/mp4" { t.Fatalf("playback mime = %q", store.objects[store.bindings[RolePlayback]].MimeType) } if store.meta != [3]int64{5500, 1280, 720} { t.Fatalf("media meta = %v, want duration/width/height", store.meta) } } func TestGenerateVideoProbeFailureFails(t *testing.T) { ffmpeg := writeScript(t, "ffmpeg", "#!/bin/sh\nexit 0\n") ffprobe := writeScript(t, "ffprobe", "#!/bin/sh\necho boom >&2\nexit 1\n") tools := video.Detect(config.Video{FFmpegPath: ffmpeg, FFprobePath: ffprobe}) store := newFakeStore(&Object{ID: "obj1", StorageKey: "objects/obj1/original.mp4", State: ObjectReady}, []string{"photo1"}) svc := &Service{store: store, storage: fakeBackend{}} w := NewWorker(svc, 0, 0, 0, 1) w.SetVideo(tools, 1) w.generateVideo(context.Background(), "photo1") if len(store.failmsgs) == 0 { t.Fatal("expected job failure on probe error") } if store.completed { t.Fatal("job must not complete after failure") } }