Files

246 lines
7.7 KiB
Go

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)
}
}