feat(photos): fix trash permanent delete and add empty trash

Permanently deleting a photo whose album was deleted failed because
authorization resolved through the album. Fall back to uploader
ownership when the photo is albumless. Add a bulk DELETE /trash that
removes every trashed photo the caller may delete (uncapped listing)
and returns the deleted count, with an «Очистить корзину» action on the
trash screen.
This commit is contained in:
iu
2026-10-11 20:54:54 +03:00
parent d5d3f55db8
commit 3b745fa737
16 changed files with 643 additions and 13 deletions
+245
View File
@@ -0,0 +1,245 @@
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)
}
}