package e2e import ( "context" "database/sql" "encoding/json" "net/http" "net/http/httptest" "path/filepath" "testing" "github.com/gin-gonic/gin" "galo/internal/albums" "galo/internal/app" "galo/internal/auth" "galo/internal/config" "galo/internal/photos" ) func TestPermanentDeleteAlbumlessPhoto(t *testing.T) { ctx := context.Background() st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) defer closeDB() user, _, err := st.auth.Register(ctx, "albumless@test.local", "password123", "A") if err != nil { t.Fatalf("register: %v", err) } album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "Doomed"}) if err != nil { t.Fatalf("create album: %v", err) } p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)}) if err != nil { t.Fatalf("upload: %v", err) } var objectID string if err := st.db.QueryRowContext(ctx, `SELECT object_id FROM photo_objects WHERE photo_id = $1 AND role = 'original'`, p.ID).Scan(&objectID); err != nil { t.Fatalf("original object: %v", err) } // Deleting the album moves its photo to the trash with no album binding. if err := st.albums.Delete(ctx, album.ID, user.ID); err != nil { t.Fatalf("delete album: %v", err) } var albumID sql.NullString if err := st.db.QueryRowContext(ctx, `SELECT album_id FROM photos WHERE id = $1`, p.ID).Scan(&albumID); err != nil { t.Fatalf("read album_id: %v", err) } if albumID.Valid { t.Fatalf("photo still bound to album %q after album deletion", albumID.String) } if err := st.photos.PermanentDelete(ctx, p.ID, user.ID); err != nil { t.Fatalf("permanent delete albumless photo: %v", err) } var n int if err := st.db.QueryRowContext(ctx, `SELECT count(*) FROM photos WHERE id = $1`, p.ID).Scan(&n); err != nil { t.Fatalf("count photos: %v", err) } if n != 0 { t.Fatalf("photo row survived permanent delete") } var state string if err := st.db.QueryRowContext(ctx, `SELECT state FROM objects WHERE id = $1`, objectID).Scan(&state); err != nil { t.Fatalf("object row missing after delete: %v", err) } if state != photos.ObjectDeleting { t.Fatalf("object state = %q, want %q", state, photos.ObjectDeleting) } } func TestEmptyTrashSkipsForeignPhotos(t *testing.T) { ctx := context.Background() st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) defer closeDB() owner, _, err := st.auth.Register(ctx, "owner@test.local", "password123", "O") if err != nil { t.Fatalf("register owner: %v", err) } member, _, err := st.auth.Register(ctx, "member@test.local", "password123", "M") if err != nil { t.Fatalf("register member: %v", err) } album, err := st.albums.Create(ctx, owner.ID, albums.CreateInput{Title: "Shared"}) if err != nil { t.Fatalf("create album: %v", err) } if _, err := st.db.ExecContext(ctx, `INSERT INTO album_members (album_id, user_id, role) VALUES ($1, $2, 'member')`, album.ID, member.ID); err != nil { t.Fatalf("add member: %v", err) } ownerPhoto, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: owner.ID, Filename: "o.png", Data: makePNG(t)}) if err != nil { t.Fatalf("upload owner photo: %v", err) } memberPhoto, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: member.ID, Filename: "m.png", Data: makePNG(t)}) if err != nil { t.Fatalf("upload member photo: %v", err) } if err := st.photos.Delete(ctx, ownerPhoto.ID, owner.ID); err != nil { t.Fatalf("trash owner photo: %v", err) } if err := st.photos.Delete(ctx, memberPhoto.ID, member.ID); err != nil { t.Fatalf("trash member photo: %v", err) } if n, _ := st.photos.TrashCount(ctx, member.ID); n != 2 { t.Fatalf("member trash count = %d, want 2", n) } deleted, err := st.photos.EmptyTrash(ctx, member.ID) if err != nil { t.Fatalf("empty trash: %v", err) } if deleted != 1 { t.Fatalf("deleted = %d, want 1 (only the member's own photo)", deleted) } var n int if err := st.db.QueryRowContext(ctx, `SELECT count(*) FROM photos WHERE id = $1`, memberPhoto.ID).Scan(&n); err != nil { t.Fatalf("count member photo: %v", err) } if n != 0 { t.Fatalf("member's own photo survived empty trash") } if err := st.db.QueryRowContext(ctx, `SELECT count(*) FROM photos WHERE id = $1`, ownerPhoto.ID).Scan(&n); err != nil { t.Fatalf("count owner photo: %v", err) } if n != 1 { t.Fatalf("foreign photo was removed by emptier") } if c, _ := st.photos.TrashCount(ctx, member.ID); c != 1 { t.Fatalf("trash count after empty = %d, want 1 (the foreign photo)", c) } // Nothing the caller may delete remains, so a second pass deletes nothing. if deleted, err := st.photos.EmptyTrash(ctx, member.ID); err != nil || deleted != 0 { t.Fatalf("second empty = %d, err=%v; want 0, nil", deleted, err) } } func TestListTrashIdentitiesNotCapped(t *testing.T) { ctx := context.Background() st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) defer closeDB() user, _, err := st.auth.Register(ctx, "many@test.local", "password123", "M") if err != nil { t.Fatalf("register: %v", err) } tx, err := st.db.BeginTx(ctx, nil) if err != nil { t.Fatalf("begin: %v", err) } for i := 0; i < 205; i++ { if _, err := tx.ExecContext(ctx, `INSERT INTO photos (id, uploader_id, deleted_at) VALUES ($1, $2, $3)`, auth.NewID(), user.ID, "2026-01-01T00:00:00Z"); err != nil { t.Fatalf("insert trashed photo %d: %v", i, err) } } if err := tx.Commit(); err != nil { t.Fatalf("commit: %v", err) } items, err := st.repos.Photos.ListTrashIdentities(ctx, user.ID) if err != nil { t.Fatalf("list trash identities: %v", err) } if len(items) != 205 { t.Fatalf("got %d identities, want 205 (display cap leaked into purge)", len(items)) } } func TestEmptyTrashReturnsZeroWhenEmpty(t *testing.T) { ctx := context.Background() st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) defer closeDB() user, _, err := st.auth.Register(ctx, "empty@test.local", "password123", "E") if err != nil { t.Fatalf("register: %v", err) } deleted, err := st.photos.EmptyTrash(ctx, user.ID) if err != nil { t.Fatalf("empty trash: %v", err) } if deleted != 0 { t.Fatalf("deleted = %d, want 0", deleted) } } func TestEmptyTrashHTTP(t *testing.T) { ctx := context.Background() st, closeDB := openStack(t, filepath.Join(t.TempDir(), "galo.db"), t.TempDir()) defer closeDB() user, pair, err := st.auth.Register(ctx, "http@test.local", "password123", "H") if err != nil { t.Fatalf("register: %v", err) } album, err := st.albums.Create(ctx, user.ID, albums.CreateInput{Title: "HTTP"}) if err != nil { t.Fatalf("create album: %v", err) } p, err := st.photos.Upload(ctx, photos.UploadInput{AlbumID: album.ID, UserID: user.ID, Filename: "a.png", Data: makePNG(t)}) if err != nil { t.Fatalf("upload: %v", err) } if err := st.photos.Delete(ctx, p.ID, user.ID); err != nil { t.Fatalf("trash photo: %v", err) } gin.SetMode(gin.TestMode) srv := app.New(&config.Config{}, st.repos, st.db.PingContext, st.tm, st.storage, st.hub) router := srv.Router() req := httptest.NewRequest(http.MethodDelete, "/api/trash", nil) req.Header.Set("Authorization", "Bearer "+pair.AccessToken) rec := httptest.NewRecorder() router.ServeHTTP(rec, req) if rec.Code != http.StatusOK { t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) } var body struct { Deleted int `json:"deleted"` } if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { t.Fatalf("decode: %v", err) } if body.Deleted != 1 { t.Fatalf("deleted = %d, want 1", body.Deleted) } if n, _ := st.photos.TrashCount(ctx, user.ID); n != 0 { t.Fatalf("trash count after empty = %d, want 0", n) } }