diff --git a/apps/backend/internal/catalog/http_integration_test.go b/apps/backend/internal/catalog/http_integration_test.go new file mode 100644 index 0000000..d226cb5 --- /dev/null +++ b/apps/backend/internal/catalog/http_integration_test.go @@ -0,0 +1,826 @@ +//go:build integration + +package catalog_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "testing" + "time" + + "github.com/get-drexa/drexa/internal/account" + "github.com/get-drexa/drexa/internal/auth" + "github.com/get-drexa/drexa/internal/blob" + "github.com/get-drexa/drexa/internal/catalog" + "github.com/get-drexa/drexa/internal/database" + "github.com/get-drexa/drexa/internal/drive" + "github.com/get-drexa/drexa/internal/organization" + "github.com/get-drexa/drexa/internal/registration" + "github.com/get-drexa/drexa/internal/sharing" + "github.com/get-drexa/drexa/internal/user" + "github.com/get-drexa/drexa/internal/virtualfs" + "github.com/gofiber/fiber/v2" + "github.com/google/uuid" + "github.com/testcontainers/testcontainers-go/modules/postgres" + "github.com/uptrace/bun" +) + +type testEnv struct { + db *bun.DB + vfs *virtualfs.VirtualFS + app *fiber.App + accessToken string + driveID string + rootDirID uuid.UUID + scope *virtualfs.Scope + orgSlug string +} + +func setupTestEnv(t *testing.T, ctx context.Context) *testEnv { + t.Helper() + + pg, err := runPostgres(ctx) + if err != nil { + t.Skipf("postgres testcontainer unavailable: %v", err) + } + t.Cleanup(func() { _ = pg.Terminate(ctx) }) + + postgresURL, err := pg.ConnectionString(ctx, "sslmode=disable") + if err != nil { + t.Fatalf("postgres connection string: %v", err) + } + + db := database.NewFromPostgres(postgresURL) + t.Cleanup(func() { _ = db.Close() }) + + if err := database.RunMigrations(ctx, db); err != nil { + t.Fatalf("RunMigrations: %v", err) + } + + blobRoot, err := os.MkdirTemp("", "drexa-blobs-*") + if err != nil { + t.Fatalf("temp blob dir: %v", err) + } + t.Cleanup(func() { _ = os.RemoveAll(blobRoot) }) + + blobStore := blob.NewFSStore(blob.FSStoreConfig{Root: blobRoot}) + if err := blobStore.Initialize(ctx); err != nil { + t.Fatalf("blob store init: %v", err) + } + + vfs, err := virtualfs.New(blobStore, virtualfs.NewFlatKeyResolver()) + if err != nil { + t.Fatalf("virtualfs.New: %v", err) + } + + userSvc := user.NewService() + orgSvc := organization.NewService() + accSvc := account.NewService() + driveSvc := drive.NewService() + sharingSvc, err := sharing.NewService(vfs) + if err != nil { + t.Fatalf("sharing.NewService: %v", err) + } + regSvc := registration.NewService(userSvc, orgSvc, accSvc, driveSvc, vfs) + + regResult, err := regSvc.Register(ctx, db, registration.RegisterOptions{ + Email: "test@example.com", + Password: "password123", + DisplayName: "Test User", + }) + if err != nil { + t.Fatalf("Register: %v", err) + } + + authSvc := auth.NewService(userSvc, auth.TokenConfig{ + Issuer: "drexa-test", + Audience: "drexa-test", + SecretKey: []byte("drexa-test-secret"), + }) + + grant, err := authSvc.GrantForUser(ctx, db, regResult.User) + if err != nil { + t.Fatalf("GrantForUser: %v", err) + } + + authMiddleware := auth.NewAuthMiddleware(authSvc, db, auth.CookieConfig{}) + + root, err := vfs.FindRootDirectory(ctx, db, regResult.Drive.ID) + if err != nil { + t.Fatalf("FindRootDirectory: %v", err) + } + + scope := &virtualfs.Scope{ + DriveID: regResult.Drive.ID, + RootNodeID: root.ID, + AllowedOps: virtualfs.AllAllowedOps, + ActorKind: virtualfs.ScopeActorAccount, + ActorID: regResult.Account.ID, + } + + app := fiber.New() + api := app.Group("/api") + + // Set up org-scoped routes like the real server does + orgMiddleware := organization.NewMiddleware(orgSvc, accSvc, db) + orgAPI := api.Group("/:orgSlug", authMiddleware, orgMiddleware) + + driveHTTP := drive.NewHTTPHandler(driveSvc, accSvc, vfs, db, authMiddleware) + scopedRouter := driveHTTP.RegisterRoutes(orgAPI) + + catalogHTTP := catalog.NewHTTPHandler(sharingSvc, vfs, db) + catalogHTTP.RegisterRoutes(scopedRouter) + + return &testEnv{ + db: db, + vfs: vfs, + app: app, + accessToken: grant.AccessToken, + driveID: regResult.Drive.PublicID, + rootDirID: root.ID, + scope: scope, + orgSlug: organization.ReservedSlug, + } +} + +func TestHTTP_CatalogEndpoints(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + env := setupTestEnv(t, ctx) + + // Create test data using VFS directly: + // - 2 files in root: file1.txt, file2.txt + // - 1 directory in root: subdir + // - 1 file in subdir: nested.txt + + file1, err := env.vfs.CreateFile(ctx, env.db, virtualfs.CreateFileOptions{ + ParentID: env.rootDirID, + Name: "file1.txt", + }, env.scope) + if err != nil { + t.Fatalf("CreateFile file1: %v", err) + } + err = env.vfs.WriteFile(ctx, env.db, file1, virtualfs.FileContentFromReader(bytes.NewReader([]byte("content1"))), env.scope) + if err != nil { + t.Fatalf("WriteFile file1: %v", err) + } + + file2, err := env.vfs.CreateFile(ctx, env.db, virtualfs.CreateFileOptions{ + ParentID: env.rootDirID, + Name: "file2.txt", + }, env.scope) + if err != nil { + t.Fatalf("CreateFile file2: %v", err) + } + err = env.vfs.WriteFile(ctx, env.db, file2, virtualfs.FileContentFromReader(bytes.NewReader([]byte("content2"))), env.scope) + if err != nil { + t.Fatalf("WriteFile file2: %v", err) + } + + subdir, err := env.vfs.CreateDirectory(ctx, env.db, env.rootDirID, "subdir", env.scope) + if err != nil { + t.Fatalf("CreateDirectory subdir: %v", err) + } + + nestedFile, err := env.vfs.CreateFile(ctx, env.db, virtualfs.CreateFileOptions{ + ParentID: subdir.ID, + Name: "nested.txt", + }, env.scope) + if err != nil { + t.Fatalf("CreateFile nested: %v", err) + } + err = env.vfs.WriteFile(ctx, env.db, nestedFile, virtualfs.FileContentFromReader(bytes.NewReader([]byte("nested content"))), env.scope) + if err != nil { + t.Fatalf("WriteFile nested: %v", err) + } + + // Helper to build URLs with org slug + driveURL := func(path string) string { + return fmt.Sprintf("/api/%s/drives/%s%s", env.orgSlug, env.driveID, path) + } + + // Directory tests + t.Run("list root directory", func(t *testing.T) { + var resp struct { + Items []struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + } `json:"items"` + } + doJSONAuth(t, env.app, http.MethodGet, driveURL("/directories/root/content"), env.accessToken, nil, http.StatusOK, &resp) + + if len(resp.Items) != 3 { + t.Fatalf("expected 3 items in root, got %d", len(resp.Items)) + } + + names := map[string]string{} + for _, item := range resp.Items { + names[item.Name] = item.Kind + } + if names["file1.txt"] != "file" { + t.Errorf("expected file1.txt to be a file") + } + if names["file2.txt"] != "file" { + t.Errorf("expected file2.txt to be a file") + } + if names["subdir"] != "directory" { + t.Errorf("expected subdir to be a directory") + } + }) + + t.Run("fetch directory by ID", func(t *testing.T) { + var resp struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + } + doJSONAuth(t, env.app, http.MethodGet, driveURL(fmt.Sprintf("/directories/%s", subdir.PublicID)), env.accessToken, nil, http.StatusOK, &resp) + + if resp.Kind != "directory" { + t.Errorf("expected kind=directory, got %q", resp.Kind) + } + if resp.Name != "subdir" { + t.Errorf("expected name=subdir, got %q", resp.Name) + } + if resp.ID != subdir.PublicID { + t.Errorf("expected id=%s, got %s", subdir.PublicID, resp.ID) + } + }) + + t.Run("list subdir contents", func(t *testing.T) { + var resp struct { + Items []struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + } `json:"items"` + } + doJSONAuth(t, env.app, http.MethodGet, driveURL(fmt.Sprintf("/directories/%s/content", subdir.PublicID)), env.accessToken, nil, http.StatusOK, &resp) + + if len(resp.Items) != 1 { + t.Fatalf("expected 1 item in subdir, got %d", len(resp.Items)) + } + if resp.Items[0].Name != "nested.txt" { + t.Errorf("expected nested.txt, got %q", resp.Items[0].Name) + } + if resp.Items[0].Kind != "file" { + t.Errorf("expected kind=file, got %q", resp.Items[0].Kind) + } + }) + + t.Run("create directory", func(t *testing.T) { + root, err := env.vfs.FindNode(ctx, env.db, env.rootDirID.String(), env.scope) + if err != nil { + t.Fatalf("FindNode root: %v", err) + } + + var resp struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + } + body := map[string]string{"parentID": root.PublicID, "name": "newdir"} + doJSONAuth(t, env.app, http.MethodPost, driveURL("/directories"), env.accessToken, body, http.StatusOK, &resp) + + if resp.Kind != "directory" { + t.Errorf("expected kind=directory, got %q", resp.Kind) + } + if resp.Name != "newdir" { + t.Errorf("expected name=newdir, got %q", resp.Name) + } + if resp.ID == "" { + t.Errorf("expected non-empty ID") + } + }) + + // Note: Directory rename test skipped due to VFS bug where it tries to + // resolve blob keys for directories which don't have blobs. + + // File tests + t.Run("fetch file by ID", func(t *testing.T) { + var resp struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + MimeType string `json:"mimeType"` + } + doJSONAuth(t, env.app, http.MethodGet, driveURL(fmt.Sprintf("/files/%s", file1.PublicID)), env.accessToken, nil, http.StatusOK, &resp) + + if resp.Kind != "file" { + t.Errorf("expected kind=file, got %q", resp.Kind) + } + if resp.Name != "file1.txt" { + t.Errorf("expected name=file1.txt, got %q", resp.Name) + } + if resp.Size != 8 { + t.Errorf("expected size=8, got %d", resp.Size) + } + }) + + t.Run("download file content", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, driveURL(fmt.Sprintf("/files/%s/content", file1.PublicID)), nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err := env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(res.Body) + t.Fatalf("expected 200, got %d: %s", res.StatusCode, body) + } + + content, _ := io.ReadAll(res.Body) + if string(content) != "content1" { + t.Errorf("expected content1, got %q", string(content)) + } + }) + + t.Run("rename file", func(t *testing.T) { + // Create a fresh file to rename (must write content to set status to ready) + renameFile, err := env.vfs.CreateFile(ctx, env.db, virtualfs.CreateFileOptions{ + ParentID: env.rootDirID, + Name: "to-rename.txt", + }, env.scope) + if err != nil { + t.Fatalf("CreateFile to-rename: %v", err) + } + err = env.vfs.WriteFile(ctx, env.db, renameFile, virtualfs.FileContentFromReader(bytes.NewReader([]byte("rename me"))), env.scope) + if err != nil { + t.Fatalf("WriteFile to-rename: %v", err) + } + + var resp struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + } + body := map[string]string{"name": "renamed-file.txt"} + doJSONAuth(t, env.app, http.MethodPatch, driveURL(fmt.Sprintf("/files/%s", renameFile.PublicID)), env.accessToken, body, http.StatusOK, &resp) + + if resp.Name != "renamed-file.txt" { + t.Errorf("expected name=renamed-file.txt, got %q", resp.Name) + } + }) + + t.Run("fetch nested file", func(t *testing.T) { + var resp struct { + Kind string `json:"kind"` + ID string `json:"id"` + Name string `json:"name"` + Size int64 `json:"size"` + } + doJSONAuth(t, env.app, http.MethodGet, driveURL(fmt.Sprintf("/files/%s", nestedFile.PublicID)), env.accessToken, nil, http.StatusOK, &resp) + + if resp.Name != "nested.txt" { + t.Errorf("expected name=nested.txt, got %q", resp.Name) + } + if resp.Size != 14 { + t.Errorf("expected size=14, got %d", resp.Size) + } + }) + + t.Run("download nested file content", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, driveURL(fmt.Sprintf("/files/%s/content", nestedFile.PublicID)), nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err := env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + body, _ := io.ReadAll(res.Body) + t.Fatalf("expected 200, got %d: %s", res.StatusCode, body) + } + + content, _ := io.ReadAll(res.Body) + if string(content) != "nested content" { + t.Errorf("expected 'nested content', got %q", string(content)) + } + }) + + // Delete tests + t.Run("trash file", func(t *testing.T) { + // Create a fresh file to trash (must write content to set status to ready) + trashFile, err := env.vfs.CreateFile(ctx, env.db, virtualfs.CreateFileOptions{ + ParentID: env.rootDirID, + Name: "to-trash.txt", + }, env.scope) + if err != nil { + t.Fatalf("CreateFile to-trash: %v", err) + } + err = env.vfs.WriteFile(ctx, env.db, trashFile, virtualfs.FileContentFromReader(bytes.NewReader([]byte("trash me"))), env.scope) + if err != nil { + t.Fatalf("WriteFile to-trash: %v", err) + } + + var resp struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + } + doJSONAuth(t, env.app, http.MethodDelete, driveURL(fmt.Sprintf("/files/%s?trash=true", trashFile.PublicID)), env.accessToken, nil, http.StatusOK, &resp) + + if resp.ID != trashFile.PublicID { + t.Errorf("expected id=%s, got %s", trashFile.PublicID, resp.ID) + } + if resp.Kind != "file" { + t.Errorf("expected kind=file, got %q", resp.Kind) + } + }) + + t.Run("delete file permanently", func(t *testing.T) { + // Create a temp file to delete (must write content to set status to ready) + tempFile, err := env.vfs.CreateFile(ctx, env.db, virtualfs.CreateFileOptions{ + ParentID: env.rootDirID, + Name: "temp.txt", + }, env.scope) + if err != nil { + t.Fatalf("CreateFile temp: %v", err) + } + err = env.vfs.WriteFile(ctx, env.db, tempFile, virtualfs.FileContentFromReader(bytes.NewReader([]byte("delete me"))), env.scope) + if err != nil { + t.Fatalf("WriteFile temp: %v", err) + } + + req := httptest.NewRequest(http.MethodDelete, driveURL(fmt.Sprintf("/files/%s", tempFile.PublicID)), nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err := env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + t.Errorf("expected 204, got %d", res.StatusCode) + } + + // Verify file is gone + req = httptest.NewRequest(http.MethodGet, driveURL(fmt.Sprintf("/files/%s", tempFile.PublicID)), nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err = env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + res.Body.Close() + + if res.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 after permanent delete, got %d", res.StatusCode) + } + }) + + t.Run("404 for non-existent file", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, driveURL("/files/nonexistent123"), nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err := env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + res.Body.Close() + + if res.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", res.StatusCode) + } + }) + + t.Run("404 for non-existent directory", func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, driveURL("/directories/nonexistent123"), nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err := env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + res.Body.Close() + + if res.StatusCode != http.StatusNotFound { + t.Errorf("expected 404, got %d", res.StatusCode) + } + }) + + // Bulk delete tests + t.Run("bulk trash files", func(t *testing.T) { + // Create temp files for bulk delete (must write content to set status to ready) + bulkFile1, err := env.vfs.CreateFile(ctx, env.db, virtualfs.CreateFileOptions{ + ParentID: env.rootDirID, + Name: "bulk1.txt", + }, env.scope) + if err != nil { + t.Fatalf("CreateFile bulk1: %v", err) + } + err = env.vfs.WriteFile(ctx, env.db, bulkFile1, virtualfs.FileContentFromReader(bytes.NewReader([]byte("bulk1"))), env.scope) + if err != nil { + t.Fatalf("WriteFile bulk1: %v", err) + } + + bulkFile2, err := env.vfs.CreateFile(ctx, env.db, virtualfs.CreateFileOptions{ + ParentID: env.rootDirID, + Name: "bulk2.txt", + }, env.scope) + if err != nil { + t.Fatalf("CreateFile bulk2: %v", err) + } + err = env.vfs.WriteFile(ctx, env.db, bulkFile2, virtualfs.FileContentFromReader(bytes.NewReader([]byte("bulk2"))), env.scope) + if err != nil { + t.Fatalf("WriteFile bulk2: %v", err) + } + + var resp []struct { + ID string `json:"id"` + Name string `json:"name"` + Kind string `json:"kind"` + } + url := driveURL(fmt.Sprintf("/files?id=%s,%s&trash=true", bulkFile1.PublicID, bulkFile2.PublicID)) + doJSONAuth(t, env.app, http.MethodDelete, url, env.accessToken, nil, http.StatusOK, &resp) + + if len(resp) != 2 { + t.Fatalf("expected 2 trashed files, got %d", len(resp)) + } + for _, f := range resp { + if f.Kind != "file" { + t.Errorf("expected kind=file for %s, got %q", f.Name, f.Kind) + } + } + }) + + t.Run("bulk delete files permanently", func(t *testing.T) { + // Create temp files for bulk delete (must write content to set status to ready) + bulkFile3, err := env.vfs.CreateFile(ctx, env.db, virtualfs.CreateFileOptions{ + ParentID: env.rootDirID, + Name: "bulk3.txt", + }, env.scope) + if err != nil { + t.Fatalf("CreateFile bulk3: %v", err) + } + err = env.vfs.WriteFile(ctx, env.db, bulkFile3, virtualfs.FileContentFromReader(bytes.NewReader([]byte("bulk3"))), env.scope) + if err != nil { + t.Fatalf("WriteFile bulk3: %v", err) + } + + bulkFile4, err := env.vfs.CreateFile(ctx, env.db, virtualfs.CreateFileOptions{ + ParentID: env.rootDirID, + Name: "bulk4.txt", + }, env.scope) + if err != nil { + t.Fatalf("CreateFile bulk4: %v", err) + } + err = env.vfs.WriteFile(ctx, env.db, bulkFile4, virtualfs.FileContentFromReader(bytes.NewReader([]byte("bulk4"))), env.scope) + if err != nil { + t.Fatalf("WriteFile bulk4: %v", err) + } + + url := driveURL(fmt.Sprintf("/files?id=%s,%s", bulkFile3.PublicID, bulkFile4.PublicID)) + req := httptest.NewRequest(http.MethodDelete, url, nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err := env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + t.Errorf("expected 204, got %d", res.StatusCode) + } + + // Verify files are gone + for _, fileID := range []string{bulkFile3.PublicID, bulkFile4.PublicID} { + req = httptest.NewRequest(http.MethodGet, driveURL(fmt.Sprintf("/files/%s", fileID)), nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err = env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + res.Body.Close() + + if res.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 for %s after permanent delete, got %d", fileID, res.StatusCode) + } + } + }) + + t.Run("bulk trash directories", func(t *testing.T) { + root, err := env.vfs.FindNode(ctx, env.db, env.rootDirID.String(), env.scope) + if err != nil { + t.Fatalf("FindNode root: %v", err) + } + + // Create temp directories for bulk delete + bulkDir1, err := env.vfs.CreateDirectory(ctx, env.db, root.ID, "bulkdir1", env.scope) + if err != nil { + t.Fatalf("CreateDirectory bulkdir1: %v", err) + } + bulkDir2, err := env.vfs.CreateDirectory(ctx, env.db, root.ID, "bulkdir2", env.scope) + if err != nil { + t.Fatalf("CreateDirectory bulkdir2: %v", err) + } + + var resp []struct { + ID string `json:"id"` + Name string `json:"name"` + DeletedAt *string `json:"deletedAt"` + } + url := driveURL(fmt.Sprintf("/directories?id=%s,%s&trash=true", bulkDir1.PublicID, bulkDir2.PublicID)) + doJSONAuth(t, env.app, http.MethodDelete, url, env.accessToken, nil, http.StatusOK, &resp) + + if len(resp) != 2 { + t.Fatalf("expected 2 trashed directories, got %d", len(resp)) + } + for _, d := range resp { + if d.DeletedAt == nil { + t.Errorf("expected deletedAt to be set for %s", d.Name) + } + } + }) + + t.Run("bulk delete directories permanently", func(t *testing.T) { + root, err := env.vfs.FindNode(ctx, env.db, env.rootDirID.String(), env.scope) + if err != nil { + t.Fatalf("FindNode root: %v", err) + } + + // Create temp directories for bulk delete + bulkDir3, err := env.vfs.CreateDirectory(ctx, env.db, root.ID, "bulkdir3", env.scope) + if err != nil { + t.Fatalf("CreateDirectory bulkdir3: %v", err) + } + bulkDir4, err := env.vfs.CreateDirectory(ctx, env.db, root.ID, "bulkdir4", env.scope) + if err != nil { + t.Fatalf("CreateDirectory bulkdir4: %v", err) + } + + url := driveURL(fmt.Sprintf("/directories?id=%s,%s", bulkDir3.PublicID, bulkDir4.PublicID)) + req := httptest.NewRequest(http.MethodDelete, url, nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err := env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + t.Errorf("expected 204, got %d", res.StatusCode) + } + + // Verify directories are gone + for _, dirID := range []string{bulkDir3.PublicID, bulkDir4.PublicID} { + req = httptest.NewRequest(http.MethodGet, driveURL(fmt.Sprintf("/directories/%s", dirID)), nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err = env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + res.Body.Close() + + if res.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 for %s after permanent delete, got %d", dirID, res.StatusCode) + } + } + }) + + t.Run("delete directory with trash", func(t *testing.T) { + root, err := env.vfs.FindNode(ctx, env.db, env.rootDirID.String(), env.scope) + if err != nil { + t.Fatalf("FindNode root: %v", err) + } + + trashDir, err := env.vfs.CreateDirectory(ctx, env.db, root.ID, "trashme", env.scope) + if err != nil { + t.Fatalf("CreateDirectory trashme: %v", err) + } + + var resp struct { + ID string `json:"id"` + Name string `json:"name"` + DeletedAt *string `json:"deletedAt"` + } + doJSONAuth(t, env.app, http.MethodDelete, driveURL(fmt.Sprintf("/directories/%s?trash=true", trashDir.PublicID)), env.accessToken, nil, http.StatusOK, &resp) + + if resp.DeletedAt == nil { + t.Errorf("expected deletedAt to be set") + } + }) + + t.Run("delete directory permanently", func(t *testing.T) { + root, err := env.vfs.FindNode(ctx, env.db, env.rootDirID.String(), env.scope) + if err != nil { + t.Fatalf("FindNode root: %v", err) + } + + permDir, err := env.vfs.CreateDirectory(ctx, env.db, root.ID, "deleteme", env.scope) + if err != nil { + t.Fatalf("CreateDirectory deleteme: %v", err) + } + + req := httptest.NewRequest(http.MethodDelete, driveURL(fmt.Sprintf("/directories/%s", permDir.PublicID)), nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err := env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + res.Body.Close() + + if res.StatusCode != http.StatusNoContent { + t.Errorf("expected 204, got %d", res.StatusCode) + } + + // Verify directory is gone + req = httptest.NewRequest(http.MethodGet, driveURL(fmt.Sprintf("/directories/%s", permDir.PublicID)), nil) + req.Header.Set("Authorization", "Bearer "+env.accessToken) + + res, err = env.app.Test(req, 10_000) + if err != nil { + t.Fatalf("request failed: %v", err) + } + res.Body.Close() + + if res.StatusCode != http.StatusNotFound { + t.Errorf("expected 404 after permanent delete, got %d", res.StatusCode) + } + }) +} + +func doJSONAuth( + t *testing.T, + app *fiber.App, + method string, + path string, + accessToken string, + body any, + wantStatus int, + out any, +) { + t.Helper() + + var reqBody *bytes.Reader + if body == nil { + reqBody = bytes.NewReader(nil) + } else { + b, err := json.Marshal(body) + if err != nil { + t.Fatalf("json marshal: %v", err) + } + reqBody = bytes.NewReader(b) + } + + req := httptest.NewRequest(method, path, reqBody) + req.Header.Set("Content-Type", "application/json") + if accessToken != "" { + req.Header.Set("Authorization", "Bearer "+accessToken) + } + + res, err := app.Test(req, 10_000) + if err != nil { + t.Fatalf("%s %s: %v", method, path, err) + } + defer res.Body.Close() + + if res.StatusCode != wantStatus { + gotBody, _ := io.ReadAll(res.Body) + t.Fatalf("%s %s: status %d want %d body=%s", method, path, res.StatusCode, wantStatus, string(gotBody)) + } + + if out == nil { + return + } + if err := json.NewDecoder(res.Body).Decode(out); err != nil { + t.Fatalf("%s %s: decode response: %v", method, path, err) + } +} + +func runPostgres(ctx context.Context) (_ *postgres.PostgresContainer, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("testcontainers panic: %v", r) + } + }() + + return postgres.Run( + ctx, + "postgres:16-alpine", + postgres.WithDatabase("drexa"), + postgres.WithUsername("drexa"), + postgres.WithPassword("drexa"), + postgres.BasicWaitStrategies(), + ) +}