Files
tanabata/backend/internal/integration/server_test.go
T
H1K0 6d13fa425a
deploy / deploy (push) Failing after 54s
fix(backend): swap db name in URL-style test DSN
The integration suite creates a per-run database and rewrites the admin
DSN to point at it via replaceDSNDatabase. The URL-style branch was a
stub that returned the DSN unchanged, so with a URL DSN every test ran
against the shared `postgres` database instead of its own — no isolation.
The first test to create a given user/object won; the rest collided on
unique constraints, surfacing as cascades of 500s (and ImportFromFolder
seeing accumulated rows).

CI passes TANABATA_TEST_ADMIN_DSN in URL form, so this only broke in the
deploy pipeline, not local key=value runs. Parse the URL and swap its
path to the per-run database.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-03 18:13:40 +03:00

1867 lines
68 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Package integration contains end-to-end tests that start a real HTTP server
// against a disposable PostgreSQL database created on the fly.
//
// The test connects to an admin DSN (defaults to the local PG 16 socket) to
// CREATE / DROP an ephemeral database per test suite run, then runs all goose
// migrations on it.
//
// Override the admin DSN with TANABATA_TEST_ADMIN_DSN:
//
// export TANABATA_TEST_ADMIN_DSN="host=/var/run/postgresql port=5434 user=h1k0 dbname=postgres sslmode=disable"
// go test -v -timeout 120s tanabata/backend/internal/integration
package integration
import (
"bytes"
"context"
"encoding/json"
"fmt"
"image"
"image/jpeg"
"io"
"mime/multipart"
"net"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/jackc/pgx/v5/stdlib"
"github.com/pressly/goose/v3"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"tanabata/backend/internal/db/postgres"
"tanabata/backend/internal/handler"
"tanabata/backend/internal/service"
"tanabata/backend/internal/storage"
"tanabata/backend/migrations"
)
// defaultAdminDSN is the fallback when TANABATA_TEST_ADMIN_DSN is unset.
// Targets the PG 16 cluster on this machine (port 5434, Unix socket).
const defaultAdminDSN = "host=/var/run/postgresql port=5434 user=h1k0 dbname=postgres sslmode=disable"
// ---------------------------------------------------------------------------
// Test harness
// ---------------------------------------------------------------------------
type harness struct {
t *testing.T
server *httptest.Server
client *http.Client
importDir string
pool *pgxpool.Pool
// dupSvc lets duplicate tests trigger a pairs rescan directly: rebuilding the
// pairs table is a CLI/maintenance action with no HTTP endpoint.
dupSvc *service.DuplicateService
}
// setupSuite creates an ephemeral database, runs migrations, wires the full
// service graph into an httptest.Server, and registers cleanup.
func setupSuite(t *testing.T) *harness {
t.Helper()
ctx := context.Background()
// --- Create an isolated test database ------------------------------------
adminDSN := os.Getenv("TANABATA_TEST_ADMIN_DSN")
if adminDSN == "" {
adminDSN = defaultAdminDSN
}
// Use a unique name so parallel test runs don't collide.
dbName := fmt.Sprintf("tanabata_test_%d", time.Now().UnixNano())
adminConn, err := pgx.Connect(ctx, adminDSN)
require.NoError(t, err, "connect to admin DSN: %s", adminDSN)
_, err = adminConn.Exec(ctx, "CREATE DATABASE "+dbName)
require.NoError(t, err)
adminConn.Close(ctx)
// Build the DSN for the new database (replace dbname= in adminDSN).
testDSN := replaceDSNDatabase(adminDSN, dbName)
t.Cleanup(func() {
// Drop all connections then drop the database.
conn, err := pgx.Connect(context.Background(), adminDSN)
if err != nil {
return
}
defer conn.Close(context.Background())
_, _ = conn.Exec(context.Background(),
"SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = $1", dbName)
_, _ = conn.Exec(context.Background(), "DROP DATABASE IF EXISTS "+dbName)
})
// --- Migrations ----------------------------------------------------------
pool, err := pgxpool.New(ctx, testDSN)
require.NoError(t, err)
t.Cleanup(pool.Close)
migDB := stdlib.OpenDBFromPool(pool)
goose.SetBaseFS(migrations.FS)
require.NoError(t, goose.SetDialect("postgres"))
require.NoError(t, goose.Up(migDB, "."))
migDB.Close()
// --- Temp directories for storage ----------------------------------------
filesDir := t.TempDir()
thumbsDir := t.TempDir()
importDir := t.TempDir()
diskStorage, err := storage.NewDiskStorage(filesDir, thumbsDir, 160, 160, 1920, 1080, 0, 0)
require.NoError(t, err)
// --- Repositories --------------------------------------------------------
userRepo := postgres.NewUserRepo(pool)
sessionRepo := postgres.NewSessionRepo(pool)
fileRepo := postgres.NewFileRepo(pool)
mimeRepo := postgres.NewMimeRepo(pool)
aclRepo := postgres.NewACLRepo(pool)
auditRepo := postgres.NewAuditRepo(pool)
tagRepo := postgres.NewTagRepo(pool)
tagRuleRepo := postgres.NewTagRuleRepo(pool)
categoryRepo := postgres.NewCategoryRepo(pool)
poolRepo := postgres.NewPoolRepo(pool)
duplicatePairRepo := postgres.NewDuplicatePairRepo(pool)
dismissalRepo := postgres.NewDismissalRepo(pool)
transactor := postgres.NewTransactor(pool)
// --- Services ------------------------------------------------------------
authSvc := service.NewAuthService(userRepo, sessionRepo, "test-secret", 15*time.Minute, 720*time.Hour, 6*time.Hour)
aclSvc := service.NewACLService(aclRepo, fileRepo, tagRepo, categoryRepo, poolRepo, transactor)
auditSvc := service.NewAuditService(auditRepo)
tagSvc := service.NewTagService(tagRepo, tagRuleRepo, aclSvc, auditSvc, transactor)
categorySvc := service.NewCategoryService(categoryRepo, tagRepo, aclSvc, auditSvc)
poolSvc := service.NewPoolService(poolRepo, aclSvc, auditSvc)
duplicateSvc := service.NewDuplicateService(fileRepo, duplicatePairRepo, dismissalRepo, aclSvc, auditSvc, transactor, 10)
fileSvc := service.NewFileService(fileRepo, mimeRepo, diskStorage, aclSvc, auditSvc, tagSvc, transactor, importDir)
userSvc := service.NewUserService(userRepo, sessionRepo, auditSvc)
// Bootstrap the admin account the suite logs in with (replaces the old
// hardcoded seed credentials).
require.NoError(t, userSvc.EnsureAdmin(ctx, "admin", "admin"))
// --- Handlers ------------------------------------------------------------
authMiddleware := handler.NewAuthMiddleware(authSvc)
authHandler := handler.NewAuthHandler(authSvc)
fileHandler := handler.NewFileHandler(fileSvc, tagSvc, authSvc, 500<<20)
duplicateHandler := handler.NewDuplicateHandler(duplicateSvc)
tagHandler := handler.NewTagHandler(tagSvc, fileSvc)
categoryHandler := handler.NewCategoryHandler(categorySvc)
poolHandler := handler.NewPoolHandler(poolSvc)
userHandler := handler.NewUserHandler(userSvc)
aclHandler := handler.NewACLHandler(aclSvc)
auditHandler := handler.NewAuditHandler(auditSvc)
r, err := handler.NewRouter(
authMiddleware, authHandler,
fileHandler, duplicateHandler, tagHandler, categoryHandler, poolHandler,
userHandler, aclHandler, auditHandler,
"",
nil,
)
require.NoError(t, err)
srv := httptest.NewServer(r)
t.Cleanup(srv.Close)
return &harness{
t: t,
server: srv,
client: srv.Client(),
importDir: importDir,
pool: pool,
dupSvc: duplicateSvc,
}
}
// ---------------------------------------------------------------------------
// HTTP helpers
// ---------------------------------------------------------------------------
// testResponse wraps an HTTP response with the body already read into memory.
// This avoids the "body consumed by error-message arg before decode" pitfall.
type testResponse struct {
StatusCode int
bodyBytes []byte
}
// String returns the body as a string (for use in assertion messages).
func (r *testResponse) String() string { return string(r.bodyBytes) }
// decode unmarshals the body JSON into dst.
func (r *testResponse) decode(t *testing.T, dst any) {
t.Helper()
require.NoError(t, json.Unmarshal(r.bodyBytes, dst), "decode body: %s", r.String())
}
func (h *harness) url(path string) string {
return h.server.URL + "/api/v1" + path
}
// tagUses returns all activity.tag_uses rows as tag_id (text) → is_included.
func (h *harness) tagUses(ctx context.Context) map[string]bool {
h.t.Helper()
rows, err := h.pool.Query(ctx, `SELECT tag_id::text, is_included FROM activity.tag_uses`)
require.NoError(h.t, err)
defer rows.Close()
out := make(map[string]bool)
for rows.Next() {
var id string
var included bool
require.NoError(h.t, rows.Scan(&id, &included))
out[id] = included
}
require.NoError(h.t, rows.Err())
return out
}
// countTagUses returns the number of rows in activity.tag_uses.
func (h *harness) countTagUses(ctx context.Context) int {
h.t.Helper()
var n int
require.NoError(h.t, h.pool.QueryRow(ctx, `SELECT count(*) FROM activity.tag_uses`).Scan(&n))
return n
}
func (h *harness) do(method, path string, body io.Reader, token string, contentType string) *testResponse {
h.t.Helper()
req, err := http.NewRequest(method, h.url(path), body)
require.NoError(h.t, err)
if token != "" {
req.Header.Set("Authorization", "Bearer "+token)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
httpResp, err := h.client.Do(req)
require.NoError(h.t, err)
b, _ := io.ReadAll(httpResp.Body)
httpResp.Body.Close()
return &testResponse{StatusCode: httpResp.StatusCode, bodyBytes: b}
}
func (h *harness) doJSON(method, path string, payload any, token string) *testResponse {
h.t.Helper()
var buf io.Reader
if payload != nil {
b, err := json.Marshal(payload)
require.NoError(h.t, err)
buf = bytes.NewReader(b)
}
return h.do(method, path, buf, token, "application/json")
}
// login posts credentials and returns an access token.
func (h *harness) login(name, password string) string {
h.t.Helper()
resp := h.doJSON("POST", "/auth/login", map[string]string{
"name": name, "password": password,
}, "")
require.Equal(h.t, http.StatusOK, resp.StatusCode, "login failed: %s", resp)
var out struct {
AccessToken string `json:"access_token"`
}
resp.decode(h.t, &out)
require.NotEmpty(h.t, out.AccessToken)
return out.AccessToken
}
// uploadJPEG uploads a minimal valid JPEG and returns the created file object.
func (h *harness) uploadJPEG(token, originalName string) map[string]any {
h.t.Helper()
var buf bytes.Buffer
mw := multipart.NewWriter(&buf)
fw, err := mw.CreateFormFile("file", originalName)
require.NoError(h.t, err)
_, err = fw.Write(minimalJPEG())
require.NoError(h.t, err)
require.NoError(h.t, mw.Close())
resp := h.do("POST", "/files", &buf, token, mw.FormDataContentType())
require.Equal(h.t, http.StatusCreated, resp.StatusCode, "upload failed: %s", resp)
var out map[string]any
resp.decode(h.t, &out)
return out
}
// ---------------------------------------------------------------------------
// Main integration test
// ---------------------------------------------------------------------------
func TestFullFlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
// =========================================================================
// 1. Admin login (seeded by 007_seed_data.sql)
// =========================================================================
adminToken := h.login("admin", "admin")
// =========================================================================
// 2. Create a regular user
// =========================================================================
resp := h.doJSON("POST", "/users", map[string]any{
"name": "alice", "password": "alicepass", "can_create": true,
}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var aliceUser map[string]any
resp.decode(t, &aliceUser)
assert.Equal(t, "alice", aliceUser["name"])
// Create a second regular user for ACL testing.
resp = h.doJSON("POST", "/users", map[string]any{
"name": "bob", "password": "bobpass", "can_create": true,
}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
// =========================================================================
// 3. Log in as alice
// =========================================================================
aliceToken := h.login("alice", "alicepass")
bobToken := h.login("bob", "bobpass")
// =========================================================================
// 4. Alice uploads a private JPEG
// =========================================================================
fileObj := h.uploadJPEG(aliceToken, "sunset.jpg")
fileID, ok := fileObj["id"].(string)
require.True(t, ok, "file id missing")
assert.Equal(t, "sunset.jpg", fileObj["original_name"])
assert.Equal(t, false, fileObj["is_public"])
// =========================================================================
// 5. Create a tag and assign it to the file
// =========================================================================
resp = h.doJSON("POST", "/tags", map[string]any{
"name": "nature", "is_public": true,
}, aliceToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var tagObj map[string]any
resp.decode(t, &tagObj)
tagID := tagObj["id"].(string)
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
"tag_ids": []string{tagID},
}, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// Verify tag is returned with the file.
resp = h.doJSON("GET", "/files/"+fileID, nil, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var fileWithTags map[string]any
resp.decode(t, &fileWithTags)
tags := fileWithTags["tags"].([]any)
require.Len(t, tags, 1)
assert.Equal(t, "nature", tags[0].(map[string]any)["name"])
// =========================================================================
// 6. Filter files by tag
// =========================================================================
resp = h.doJSON("GET", "/files?filter=%7Bt%3D"+tagID+"%7D", nil, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var filePage map[string]any
resp.decode(t, &filePage)
items := filePage["items"].([]any)
require.Len(t, items, 1)
assert.Equal(t, fileID, items[0].(map[string]any)["id"])
// =========================================================================
// 7. ACL — Bob cannot see Alice's private file
// =========================================================================
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// Grant Bob view access.
bobUserID := int(aliceUser["id"].(float64)) // alice's id used for reference; get bob's
// Resolve bob's real ID via admin.
resp = h.doJSON("GET", "/users", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var usersPage map[string]any
resp.decode(t, &usersPage)
var bobID float64
for _, u := range usersPage["items"].([]any) {
um := u.(map[string]any)
if um["name"] == "bob" {
bobID = um["id"].(float64)
}
}
_ = bobUserID
require.NotZero(t, bobID)
resp = h.doJSON("PUT", "/acl/file/"+fileID, map[string]any{
"permissions": []map[string]any{
{"user_id": bobID, "can_view": true, "can_edit": false},
},
}, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// Now Bob can view.
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// =========================================================================
// 8. Create a pool and add the file
// =========================================================================
resp = h.doJSON("POST", "/pools", map[string]any{
"name": "alice's pool", "is_public": false,
}, aliceToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var poolObj map[string]any
resp.decode(t, &poolObj)
poolID := poolObj["id"].(string)
assert.Equal(t, "alice's pool", poolObj["name"])
resp = h.doJSON("POST", "/pools/"+poolID+"/files", map[string]any{
"file_ids": []string{fileID},
}, aliceToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
// Pool file count should now be 1.
resp = h.doJSON("GET", "/pools/"+poolID, nil, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var poolFull map[string]any
resp.decode(t, &poolFull)
assert.Equal(t, float64(1), poolFull["file_count"])
// List pool files and verify position.
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var poolFiles map[string]any
resp.decode(t, &poolFiles)
poolItems := poolFiles["items"].([]any)
require.Len(t, poolItems, 1)
assert.Equal(t, fileID, poolItems[0].(map[string]any)["id"])
// =========================================================================
// 9. Trash flow: soft-delete → list trash → restore → permanent delete
// =========================================================================
// Soft-delete the file.
resp = h.doJSON("DELETE", "/files/"+fileID, nil, aliceToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// File no longer appears in normal listing.
resp = h.doJSON("GET", "/files", nil, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var normalPage map[string]any
resp.decode(t, &normalPage)
normalItems, _ := normalPage["items"].([]any)
assert.Len(t, normalItems, 0)
// File appears in trash listing.
resp = h.doJSON("GET", "/files?trash=true", nil, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var trashPage map[string]any
resp.decode(t, &trashPage)
trashItems := trashPage["items"].([]any)
require.Len(t, trashItems, 1)
assert.Equal(t, fileID, trashItems[0].(map[string]any)["id"])
assert.Equal(t, true, trashItems[0].(map[string]any)["is_deleted"])
// Restore the file.
resp = h.doJSON("POST", "/files/"+fileID+"/restore", nil, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// File is back in normal listing.
resp = h.doJSON("GET", "/files", nil, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var restoredPage map[string]any
resp.decode(t, &restoredPage)
restoredItems := restoredPage["items"].([]any)
require.Len(t, restoredItems, 1)
assert.Equal(t, fileID, restoredItems[0].(map[string]any)["id"])
// Soft-delete again then permanently delete.
resp = h.doJSON("DELETE", "/files/"+fileID, nil, aliceToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode)
resp = h.doJSON("DELETE", "/files/"+fileID+"/permanent", nil, aliceToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// File is gone entirely.
resp = h.doJSON("GET", "/files/"+fileID, nil, aliceToken)
assert.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
// =========================================================================
// 10. Audit log records actions (admin only)
// =========================================================================
resp = h.doJSON("GET", "/audit", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var auditPage map[string]any
resp.decode(t, &auditPage)
auditItems := auditPage["items"].([]any)
assert.NotEmpty(t, auditItems, "audit log should have entries")
// Non-admin cannot read the audit log.
resp = h.doJSON("GET", "/audit", nil, aliceToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
}
// ---------------------------------------------------------------------------
// Additional targeted tests
// ---------------------------------------------------------------------------
// TestBlockedUserCannotLogin verifies that blocking a user prevents login.
func TestBlockedUserCannotLogin(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
// Create user.
resp := h.doJSON("POST", "/users", map[string]any{
"name": "charlie", "password": "charliepass",
}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var u map[string]any
resp.decode(t, &u)
userID := u["id"].(float64)
// Block charlie.
resp = h.doJSON("PATCH", fmt.Sprintf("/users/%.0f", userID), map[string]any{
"is_blocked": true,
}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
// Login attempt should return 403.
resp = h.doJSON("POST", "/auth/login", map[string]any{
"name": "charlie", "password": "charliepass",
}, "")
assert.Equal(t, http.StatusForbidden, resp.StatusCode)
}
// TestPoolReorder verifies gap-based position reassignment.
func TestPoolReorder(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
// Upload two files.
f1 := h.uploadJPEG(adminToken, "a.jpg")
f2 := h.uploadJPEG(adminToken, "b.jpg")
id1 := f1["id"].(string)
id2 := f2["id"].(string)
// Create pool and add both files.
resp := h.doJSON("POST", "/pools", map[string]any{"name": "reorder-test"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var pool map[string]any
resp.decode(t, &pool)
poolID := pool["id"].(string)
resp = h.doJSON("POST", "/pools/"+poolID+"/files", map[string]any{
"file_ids": []string{id1, id2},
}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode)
// Verify initial order: id1 before id2.
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var page map[string]any
resp.decode(t, &page)
items := page["items"].([]any)
require.Len(t, items, 2)
assert.Equal(t, id1, items[0].(map[string]any)["id"])
assert.Equal(t, id2, items[1].(map[string]any)["id"])
// Reorder: id2 first.
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder", map[string]any{
"file_ids": []string{id2, id1},
}, adminToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// Verify new order.
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var page2 map[string]any
resp.decode(t, &page2)
items2 := page2["items"].([]any)
require.Len(t, items2, 2)
assert.Equal(t, id2, items2[0].(map[string]any)["id"])
assert.Equal(t, id1, items2[1].(map[string]any)["id"])
}
// TestTagRuleActivateApplyToExisting verifies that activating a rule with
// apply_to_existing=true retroactively tags existing files, including
// transitive rules (A→B active+apply, B→C already active → file gets A,B,C).
func TestTagRuleActivateApplyToExisting(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
tok := h.login("admin", "admin")
// Create three tags: A, B, C.
mkTag := func(name string) string {
resp := h.doJSON("POST", "/tags", map[string]any{"name": name}, tok)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var obj map[string]any
resp.decode(t, &obj)
return obj["id"].(string)
}
tagA := mkTag("animal")
tagB := mkTag("living-thing")
tagC := mkTag("organism")
// Rule A→B: created inactive so it does NOT fire on assign.
resp := h.doJSON("POST", "/tags/"+tagA+"/rules", map[string]any{
"then_tag_id": tagB,
"is_active": false,
}, tok)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
// Rule B→C: active, so it fires transitively when B is applied.
resp = h.doJSON("POST", "/tags/"+tagB+"/rules", map[string]any{
"then_tag_id": tagC,
"is_active": true,
}, tok)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
// Upload a file and assign only tag A. A→B is inactive so only A is set.
file := h.uploadJPEG(tok, "cat.jpg")
fileID := file["id"].(string)
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
"tag_ids": []string{tagA},
}, tok)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
tagNames := func() []string {
r := h.doJSON("GET", "/files/"+fileID+"/tags", nil, tok)
require.Equal(t, http.StatusOK, r.StatusCode)
var items []any
r.decode(t, &items)
names := make([]string, 0, len(items))
for _, it := range items {
names = append(names, it.(map[string]any)["name"].(string))
}
return names
}
// Before activation: file should only have tag A.
assert.ElementsMatch(t, []string{"animal"}, tagNames())
// Activate A→B WITHOUT apply_to_existing — existing file must not change.
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
"is_active": true,
"apply_to_existing": false,
}, tok)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
assert.ElementsMatch(t, []string{"animal"}, tagNames(), "file should be unchanged when apply_to_existing=false")
// Deactivate again so we can test the positive case cleanly.
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
"is_active": false,
}, tok)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// Activate A→B WITH apply_to_existing=true.
// Expectation: file gets B directly, and C transitively via the active B→C rule.
resp = h.doJSON("PATCH", "/tags/"+tagA+"/rules/"+tagB, map[string]any{
"is_active": true,
"apply_to_existing": true,
}, tok)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
assert.ElementsMatch(t, []string{"animal", "living-thing", "organism"}, tagNames())
}
// TestTagRuleCreateApplyToExisting verifies that *creating* a rule with
// apply_to_existing=true retroactively tags existing files — the same contract
// as activating one (TestTagRuleActivateApplyToExisting), exercised through the
// POST path. Also checks that apply_to_existing=false and an inactive rule both
// leave existing files untouched.
func TestTagRuleCreateApplyToExisting(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
tok := h.login("admin", "admin")
mkTag := func(name string) string {
resp := h.doJSON("POST", "/tags", map[string]any{"name": name}, tok)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var obj map[string]any
resp.decode(t, &obj)
return obj["id"].(string)
}
tagA := mkTag("animal")
tagB := mkTag("living-thing")
tagC := mkTag("organism")
// Rule B→C: active, so it fires transitively once B lands on the file.
resp := h.doJSON("POST", "/tags/"+tagB+"/rules", map[string]any{
"then_tag_id": tagC,
"is_active": true,
}, tok)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
// A file carrying only tag A — no A→B rule exists yet.
file := h.uploadJPEG(tok, "cat.jpg")
fileID := file["id"].(string)
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
"tag_ids": []string{tagA},
}, tok)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
tagNames := func() []string {
r := h.doJSON("GET", "/files/"+fileID+"/tags", nil, tok)
require.Equal(t, http.StatusOK, r.StatusCode)
var items []any
r.decode(t, &items)
names := make([]string, 0, len(items))
for _, it := range items {
names = append(names, it.(map[string]any)["name"].(string))
}
return names
}
assert.ElementsMatch(t, []string{"animal"}, tagNames())
// Creating an INACTIVE rule, even with apply_to_existing=true, must not tag.
resp = h.doJSON("POST", "/tags/"+tagA+"/rules", map[string]any{
"then_tag_id": tagB,
"is_active": false,
"apply_to_existing": true,
}, tok)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
assert.ElementsMatch(t, []string{"animal"}, tagNames(), "inactive rule must not tag existing files")
// Drop it so we can recreate as active.
resp = h.doJSON("DELETE", "/tags/"+tagA+"/rules/"+tagB, nil, tok)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// Creating an ACTIVE rule with apply_to_existing=false leaves the file alone.
resp = h.doJSON("POST", "/tags/"+tagA+"/rules", map[string]any{
"then_tag_id": tagB,
"is_active": true,
"apply_to_existing": false,
}, tok)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
assert.ElementsMatch(t, []string{"animal"}, tagNames(), "apply_to_existing=false must not touch existing files")
resp = h.doJSON("DELETE", "/tags/"+tagA+"/rules/"+tagB, nil, tok)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// Creating A→B active WITH apply_to_existing=true: the file gets B directly
// and C transitively via the already-active B→C rule.
resp = h.doJSON("POST", "/tags/"+tagA+"/rules", map[string]any{
"then_tag_id": tagB,
"is_active": true,
"apply_to_existing": true,
}, tok)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
assert.ElementsMatch(t, []string{"animal", "living-thing", "organism"}, tagNames())
}
// TestTagAutoRule verifies that adding a tag automatically applies then_tags.
func TestTagAutoRule(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
// Create two tags: "outdoor" and "nature".
resp := h.doJSON("POST", "/tags", map[string]any{"name": "outdoor"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var outdoor map[string]any
resp.decode(t, &outdoor)
outdoorID := outdoor["id"].(string)
resp = h.doJSON("POST", "/tags", map[string]any{"name": "nature"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var nature map[string]any
resp.decode(t, &nature)
natureID := nature["id"].(string)
// Create rule: when "outdoor" → also apply "nature".
resp = h.doJSON("POST", "/tags/"+outdoorID+"/rules", map[string]any{
"then_tag_id": natureID,
}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
// Upload a file and assign only "outdoor".
file := h.uploadJPEG(adminToken, "park.jpg")
fileID := file["id"].(string)
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
"tag_ids": []string{outdoorID},
}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// Both "outdoor" and "nature" should be on the file.
resp = h.doJSON("GET", "/files/"+fileID+"/tags", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var tagsResp []any
resp.decode(t, &tagsResp)
names := make([]string, 0, len(tagsResp))
for _, tg := range tagsResp {
names = append(names, tg.(map[string]any)["name"].(string))
}
assert.ElementsMatch(t, []string{"outdoor", "nature"}, names)
}
// TestRecordFileView verifies that viewing a file is logged (POST .../views),
// is repeatable (view history, not a unique flag), and 404s for unknown files.
func TestRecordFileView(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
file := h.uploadJPEG(adminToken, "seen.jpg")
fileID := file["id"].(string)
resp := h.doJSON("POST", "/files/"+fileID+"/views", nil, adminToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// Viewing again logs another history row, not a conflict.
resp = h.doJSON("POST", "/files/"+fileID+"/views", nil, adminToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// Unknown file id → 404.
resp = h.doJSON("POST", "/files/00000000-0000-0000-0000-000000000000/views", nil, adminToken)
require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
}
// TestRecordTagUses verifies that filtering files by tags logs to
// activity.tag_uses — included tags as is_included=true, negated ones as
// false — while an unfiltered listing and follow-up pagination record nothing.
func TestRecordTagUses(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
ctx := context.Background()
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/tags", map[string]any{"name": "sea"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var sea map[string]any
resp.decode(t, &sea)
seaID := sea["id"].(string)
resp = h.doJSON("POST", "/tags", map[string]any{"name": "sky"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var sky map[string]any
resp.decode(t, &sky)
skyID := sky["id"].(string)
// Two files both tagged "sea", so a paged {t=sea} listing has a second page.
for _, name := range []string{"a.jpg", "b.jpg"} {
f := h.uploadJPEG(adminToken, name)
resp = h.doJSON("PUT", "/files/"+f["id"].(string)+"/tags",
map[string]any{"tag_ids": []string{seaID}}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// An unfiltered listing must not touch tag_uses.
resp = h.doJSON("GET", "/files", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
require.Equal(t, 0, h.countTagUses(ctx), "unfiltered list should record nothing")
// Include "sea": {t=sea}, one item per page so a next_cursor comes back.
resp = h.doJSON("GET", "/files?limit=1&filter=%7Bt%3D"+seaID+"%7D", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page1 map[string]any
resp.decode(t, &page1)
nextCursor, _ := page1["next_cursor"].(string)
require.NotEmpty(t, nextCursor, "expected a next_cursor for page 2")
// Exclude "sky": {!,t=sky}
resp = h.doJSON("GET", "/files?filter=%7B%21%2Ct%3D"+skyID+"%7D", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
uses := h.tagUses(ctx)
require.Len(t, uses, 2, "expected one row per filtered tag")
assert.True(t, uses[seaID], "included tag should be is_included=true")
assert.False(t, uses[skyID], "negated tag should be is_included=false")
// Page 2 (cursor present) is pagination, not a fresh filter — no new row.
resp = h.doJSON("GET", "/files?limit=1&cursor="+nextCursor+"&filter=%7Bt%3D"+seaID+"%7D",
nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
assert.Equal(t, 2, h.countTagUses(ctx), "pagination should not add tag_uses rows")
}
// TestTagSortByCategoryThenName verifies the category_name sort groups tags by
// category and orders them by their own name within each category, with
// uncategorized tags last (NULLS LAST).
func TestTagSortByCategoryThenName(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
mkCategory := func(name string) string {
resp := h.doJSON("POST", "/categories", map[string]any{"name": name}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var c map[string]any
resp.decode(t, &c)
return c["id"].(string)
}
mkTag := func(name string, categoryID *string) {
body := map[string]any{"name": name}
if categoryID != nil {
body["category_id"] = *categoryID
}
resp := h.doJSON("POST", "/tags", body, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
}
alpha := mkCategory("Alpha")
bravo := mkCategory("Bravo")
// Insert out of order to prove the sort, not insertion order, decides output.
mkTag("zebra", &alpha)
mkTag("mid", &bravo)
mkTag("solo", nil) // uncategorized
mkTag("ant", &alpha)
resp := h.doJSON("GET", "/tags?sort=category_name&order=asc", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page map[string]any
resp.decode(t, &page)
items := page["items"].([]any)
names := make([]string, len(items))
for i, it := range items {
names[i] = it.(map[string]any)["name"].(string)
}
// Alpha (ant, zebra) → Bravo (mid) → uncategorized (solo) last.
assert.Equal(t, []string{"ant", "zebra", "mid", "solo"}, names)
}
// TestRecordPoolView verifies that viewing a pool is logged (POST .../views),
// is repeatable (view history, not a unique flag), and 404s for unknown pools.
func TestRecordPoolView(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
ctx := context.Background()
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/pools", map[string]any{"name": "trip"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var pool map[string]any
resp.decode(t, &pool)
poolID := pool["id"].(string)
resp = h.doJSON("POST", "/pools/"+poolID+"/views", nil, adminToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// Viewing again logs another history row, not a conflict.
resp = h.doJSON("POST", "/pools/"+poolID+"/views", nil, adminToken)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
var n int
require.NoError(t, h.pool.QueryRow(ctx,
`SELECT count(*) FROM activity.pool_views WHERE pool_id = $1`, poolID).Scan(&n))
assert.Equal(t, 2, n, "each view should add a history row")
// Unknown pool id → 404.
resp = h.doJSON("POST", "/pools/00000000-0000-0000-0000-000000000000/views", nil, adminToken)
require.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
}
// TestTagColorOptional verifies a tag can be created without a colour (stored as
// NULL rather than the colour input's default) and that an existing colour can
// be cleared back to none.
func TestTagColorOptional(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
// Created without a colour → color is null.
resp := h.doJSON("POST", "/tags", map[string]any{"name": "plain"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var plain map[string]any
resp.decode(t, &plain)
assert.Nil(t, plain["color"], "tag created without a colour should have null color")
// Created with a colour → kept verbatim.
resp = h.doJSON("POST", "/tags", map[string]any{"name": "red", "color": "aabbcc"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var red map[string]any
resp.decode(t, &red)
assert.Equal(t, "aabbcc", red["color"])
redID := red["id"].(string)
// Clearing the colour (color: null) must store NULL — an empty string would
// violate the hex CHECK constraint and fail the update.
resp = h.doJSON("PATCH", "/tags/"+redID, map[string]any{"color": nil}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var cleared map[string]any
resp.decode(t, &cleared)
assert.Nil(t, cleared["color"], "cleared colour should be null")
}
// TestBulkTagAutoRule verifies the bulk add path also applies then_tags.
func TestBulkTagAutoRule(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/tags", map[string]any{"name": "outdoor"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var outdoor map[string]any
resp.decode(t, &outdoor)
outdoorID := outdoor["id"].(string)
resp = h.doJSON("POST", "/tags", map[string]any{"name": "nature"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode)
var nature map[string]any
resp.decode(t, &nature)
natureID := nature["id"].(string)
resp = h.doJSON("POST", "/tags/"+outdoorID+"/rules", map[string]any{"then_tag_id": natureID}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
file := h.uploadJPEG(adminToken, "park.jpg")
fileID := file["id"].(string)
// Bulk-add only "outdoor" to the file.
resp = h.doJSON("POST", "/files/bulk/tags", map[string]any{
"file_ids": []string{fileID},
"action": "add",
"tag_ids": []string{outdoorID},
}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// The auto-applied "nature" should be on the file too.
resp = h.doJSON("GET", "/files/"+fileID+"/tags", nil, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode)
var tagsResp []any
resp.decode(t, &tagsResp)
names := make([]string, 0, len(tagsResp))
for _, tg := range tagsResp {
names = append(names, tg.(map[string]any)["name"].(string))
}
assert.ElementsMatch(t, []string{"outdoor", "nature"}, names)
}
// TestMediaQueryTokenAuth verifies the ?access_token= fallback: it authenticates
// a GET (so media can be opened via a plain link/new tab) but is rejected for a
// non-GET, and a missing token is still 401.
func TestMediaQueryTokenAuth(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
token := h.login("admin", "admin")
file := h.uploadJPEG(token, "q.jpg")
fileID := file["id"].(string)
// GET with token in the query, no Authorization header → 200.
resp := h.do("GET", "/files/"+fileID+"/content?access_token="+token, nil, "", "")
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// No token anywhere → 401.
resp = h.do("GET", "/files/"+fileID+"/content", nil, "", "")
require.Equal(t, http.StatusUnauthorized, resp.StatusCode)
// Query token must NOT authorize a state-changing (non-GET) request → 401.
resp = h.do("DELETE", "/files/"+fileID+"?access_token="+token, nil, "", "")
require.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
}
// ---------------------------------------------------------------------------
// Security regression tests
// ---------------------------------------------------------------------------
// loginPair logs in and returns the full access/refresh token pair.
func (h *harness) loginPair(name, password string) (access, refresh string) {
h.t.Helper()
resp := h.doJSON("POST", "/auth/login", map[string]string{"name": name, "password": password}, "")
require.Equal(h.t, http.StatusOK, resp.StatusCode, "login failed: %s", resp)
var out struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
resp.decode(h.t, &out)
require.NotEmpty(h.t, out.AccessToken)
require.NotEmpty(h.t, out.RefreshToken)
return out.AccessToken, out.RefreshToken
}
// TestRefreshTokenFlow verifies that refresh tokens work (regression for the
// stored-hash mismatch that made /refresh always 401), that a refresh token is
// rejected as a bearer access token, and that rotating a session revokes the
// pre-rotation access token.
func TestRefreshTokenFlow(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
access, refresh := h.loginPair("admin", "admin")
// A refresh token must not be accepted as a bearer access token.
resp := h.doJSON("GET", "/users/me", nil, refresh)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
// Refreshing yields a working new pair.
resp = h.doJSON("POST", "/auth/refresh", map[string]string{"refresh_token": refresh}, "")
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var pair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
}
resp.decode(t, &pair)
require.NotEmpty(t, pair.AccessToken)
resp = h.doJSON("GET", "/users/me", nil, pair.AccessToken)
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// The pre-rotation access token is now revoked (its session was rotated away).
resp = h.doJSON("GET", "/users/me", nil, access)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
}
// TestNonOwnerAccessControl verifies that a non-owner, non-admin user cannot
// read or change another user's object ACL, cannot view or tag another user's
// private file, and cannot trigger a server-side import.
func TestNonOwnerAccessControl(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
mkUser := func(name, pass string) {
resp := h.doJSON("POST", "/users", map[string]any{
"name": name, "password": pass, "can_create": true,
}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
}
mkUser("alice", "alicepass")
mkUser("bob", "bobpass")
aliceToken := h.login("alice", "alicepass")
bobToken := h.login("bob", "bobpass")
// Alice uploads a private file.
file := h.uploadJPEG(aliceToken, "secret.jpg")
fileID := file["id"].(string)
// Bob cannot read the file's ACL...
resp := h.doJSON("GET", "/acl/file/"+fileID, nil, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// ...nor grant himself access.
resp = h.doJSON("PUT", "/acl/file/"+fileID, map[string]any{
"permissions": []map[string]any{{"user_id": 2, "can_view": true, "can_edit": true}},
}, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// ...and still cannot view it.
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// Bob cannot list or modify tags on Alice's private file.
resp = h.doJSON("GET", "/files/"+fileID+"/tags", nil, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
"tag_ids": []string{"11111111-1111-1111-1111-111111111111"},
}, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// A non-admin cannot trigger a server-side import.
resp = h.doJSON("POST", "/files/import", map[string]any{}, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
// The owner can still manage her own file's ACL.
resp = h.doJSON("GET", "/acl/file/"+fileID, nil, aliceToken)
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// importEvent mirrors service.ImportEvent for decoding the streamed progress.
type importEvent struct {
Type string `json:"type"`
Total int `json:"total"`
Index int `json:"index"`
Filename string `json:"filename"`
Status string `json:"status"`
Reason string `json:"reason"`
Imported int `json:"imported"`
Skipped int `json:"skipped"`
Errors int `json:"errors"`
}
// parseImportEvents splits an NDJSON import response into its events.
func parseImportEvents(t *testing.T, resp *testResponse) []importEvent {
t.Helper()
var events []importEvent
for _, line := range bytes.Split(resp.bodyBytes, []byte("\n")) {
line = bytes.TrimSpace(line)
if len(line) == 0 {
continue
}
var ev importEvent
require.NoError(t, json.Unmarshal(line, &ev), "event line: %s", line)
events = append(events, ev)
}
return events
}
// TestImportFromFolder verifies the admin server-side import: supported files
// are ingested, subdirectories are skipped, the source is removed from the
// import folder afterwards, and a file without EXIF takes the source's mtime as
// its content_datetime.
func TestImportFromFolder(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
// Drop a non-EXIF JPEG into the import folder with a known mtime, plus a
// subdirectory that must be skipped.
srcPath := filepath.Join(h.importDir, "scan.jpg")
require.NoError(t, os.WriteFile(srcPath, minimalJPEG(), 0o644))
mtime := time.Date(2021, 3, 4, 5, 6, 7, 0, time.UTC)
require.NoError(t, os.Chtimes(srcPath, mtime, mtime))
require.NoError(t, os.Mkdir(filepath.Join(h.importDir, "nested"), 0o755))
resp := h.doJSON("POST", "/files/import", map[string]any{}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// The import streams newline-delimited JSON progress events.
events := parseImportEvents(t, resp)
var start, done *importEvent
files := map[string]importEvent{}
for i := range events {
switch events[i].Type {
case "start":
start = &events[i]
case "done":
done = &events[i]
case "file":
files[events[i].Filename] = events[i]
}
}
require.NotNil(t, start, resp.String())
require.NotNil(t, done, resp.String())
assert.Equal(t, 2, start.Total, "start total counts every entry")
assert.Equal(t, 1, done.Imported, resp.String())
assert.Equal(t, 1, done.Skipped, resp.String()) // the nested directory
assert.Equal(t, 0, done.Errors, resp.String())
// Per-file events: the JPEG imported, the subdirectory was skipped.
assert.Equal(t, "imported", files["scan.jpg"].Status, resp.String())
assert.Equal(t, "skipped", files["nested"].Status, resp.String())
// Source file is gone from the import folder after a successful import.
_, statErr := os.Stat(srcPath)
assert.True(t, os.IsNotExist(statErr), "source should be removed after import")
// The imported file took the mtime as content_datetime (no EXIF present).
listResp := h.doJSON("GET", "/files?limit=10", nil, adminToken)
require.Equal(t, http.StatusOK, listResp.StatusCode, listResp.String())
var list struct {
Items []struct {
ContentDatetime string `json:"content_datetime"`
} `json:"items"`
}
listResp.decode(t, &list)
require.Len(t, list.Items, 1, listResp.String())
ct, err := time.Parse(time.RFC3339, list.Items[0].ContentDatetime)
require.NoError(t, err)
assert.True(t, ct.Equal(mtime), "content_datetime %v should equal mtime %v", ct, mtime)
}
// TestImportOrdersByMtime verifies that a folder import processes files in
// ascending mtime order, regardless of filename order. The three files are named
// so that alphabetical order (ReadDir's default) is the reverse of mtime order;
// the progress-event indices must follow mtime, oldest first.
func TestImportOrdersByMtime(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
// name → mtime; alphabetical order (a,b,c) is the reverse of chronological.
files := []struct {
name string
mtime time.Time
}{
{"a_newest.jpg", time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)},
{"b_middle.jpg", time.Date(2021, 1, 1, 0, 0, 0, 0, time.UTC)},
{"c_oldest.jpg", time.Date(2019, 1, 1, 0, 0, 0, 0, time.UTC)},
}
for _, f := range files {
p := filepath.Join(h.importDir, f.name)
require.NoError(t, os.WriteFile(p, minimalJPEG(), 0o644))
require.NoError(t, os.Chtimes(p, f.mtime, f.mtime))
}
resp := h.doJSON("POST", "/files/import", map[string]any{}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
events := parseImportEvents(t, resp)
idx := map[string]int{}
for _, ev := range events {
if ev.Type == "file" {
require.Equal(t, "imported", ev.Status, "%s: %s", ev.Filename, ev.Reason)
idx[ev.Filename] = ev.Index
}
}
require.Len(t, idx, 3, resp.String())
// Oldest first: c (2019) → b (2021) → a (2022).
assert.Less(t, idx["c_oldest.jpg"], idx["b_middle.jpg"], "oldest should be processed before middle")
assert.Less(t, idx["b_middle.jpg"], idx["a_newest.jpg"], "middle should be processed before newest")
}
// TestFileReviewStatus verifies the per-file "needs review" flag: new uploads
// start as needs_review=true, POST /files/bulk/review clears it, and the DSL
// filter r=0/r=1 selects reviewed/unreviewed files (also combined with others).
func TestFileReviewStatus(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
tok := h.login("admin", "admin")
file := h.uploadJPEG(tok, "review-me.jpg")
fileID := file["id"].(string)
assert.Equal(t, true, file["needs_review"], "new upload should need review")
getReview := func() bool {
r := h.doJSON("GET", "/files/"+fileID, nil, tok)
require.Equal(t, http.StatusOK, r.StatusCode, r.String())
var obj map[string]any
r.decode(t, &obj)
return obj["needs_review"].(bool)
}
listIDs := func(dsl string) []string {
r := h.doJSON("GET", "/files?filter="+url.QueryEscape(dsl), nil, tok)
require.Equal(t, http.StatusOK, r.StatusCode, r.String())
var page map[string]any
r.decode(t, &page)
ids := []string{}
for _, it := range page["items"].([]any) {
ids = append(ids, it.(map[string]any)["id"].(string))
}
return ids
}
// Before marking done: appears under r=1 (needs review), not under r=0.
assert.True(t, getReview())
assert.Contains(t, listIDs("{r=1}"), fileID)
assert.NotContains(t, listIDs("{r=0}"), fileID)
// Mark as reviewed (done) via the bulk endpoint (single-element list).
resp := h.doJSON("POST", "/files/bulk/review", map[string]any{
"file_ids": []string{fileID},
"needs_review": false,
}, tok)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
// Now reviewed: appears under r=0, not r=1; combines with a MIME predicate.
assert.False(t, getReview(), "file should no longer need review")
assert.Contains(t, listIDs("{r=0}"), fileID)
assert.NotContains(t, listIDs("{r=1}"), fileID)
assert.Contains(t, listIDs("{r=0,&,m~image/%}"), fileID)
}
// TestContentRangeRequests verifies the original-content endpoint answers a
// byte-range request with 206 Partial Content (so the browser can seek within
// audio/video) rather than streaming the whole body.
func TestContentRangeRequests(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
token := h.login("admin", "admin")
file := h.uploadJPEG(token, "clip.jpg")
id := file["id"].(string)
req, err := http.NewRequest("GET", h.url("/files/"+id+"/content?inline=1"), nil)
require.NoError(t, err)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Range", "bytes=0-9")
resp, err := h.client.Do(req)
require.NoError(t, err)
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
assert.Equal(t, http.StatusPartialContent, resp.StatusCode)
assert.Equal(t, "bytes", resp.Header.Get("Accept-Ranges"))
assert.Regexp(t, `^bytes 0-9/\d+$`, resp.Header.Get("Content-Range"))
require.Len(t, body, 10)
assert.Equal(t, minimalJPEG()[:10], body)
}
// TestBlockRevokesActiveSessions verifies that blocking a user immediately
// invalidates their outstanding access tokens.
func TestBlockRevokesActiveSessions(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/users", map[string]any{"name": "dave", "password": "davepass"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var dave map[string]any
resp.decode(t, &dave)
daveID := dave["id"].(float64)
daveToken := h.login("dave", "davepass")
resp = h.doJSON("GET", "/users/me", nil, daveToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// Block dave.
resp = h.doJSON("PATCH", fmt.Sprintf("/users/%.0f", daveID), map[string]any{"is_blocked": true}, adminToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
// Dave's previously-issued access token is now rejected.
resp = h.doJSON("GET", "/users/me", nil, daveToken)
assert.Equal(t, http.StatusUnauthorized, resp.StatusCode, resp.String())
}
// fileListIDs returns the set of file IDs visible to token via GET /files.
func (h *harness) fileListIDs(token string) map[string]bool {
h.t.Helper()
resp := h.doJSON("GET", "/files", nil, token)
require.Equal(h.t, http.StatusOK, resp.StatusCode, resp.String())
var page map[string]any
resp.decode(h.t, &page)
ids := map[string]bool{}
if items, ok := page["items"].([]any); ok {
for _, it := range items {
if m, ok := it.(map[string]any); ok {
if id, ok := m["id"].(string); ok {
ids[id] = true
}
}
}
}
return ids
}
// TestPrivateByDefaultVisibility verifies that listings only return rows the
// caller may see: private files are hidden from non-owners, public files are
// visible to all, an explicit grant reveals a private file, and admins see all.
func TestPrivateByDefaultVisibility(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/users", map[string]any{"name": "alice", "password": "alicepass"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
resp = h.doJSON("POST", "/users", map[string]any{"name": "bob", "password": "bobpass"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var bob map[string]any
resp.decode(t, &bob)
bobID := bob["id"].(float64)
aliceToken := h.login("alice", "alicepass")
bobToken := h.login("bob", "bobpass")
file := h.uploadJPEG(aliceToken, "alice-secret.jpg")
fileID := file["id"].(string)
// Owner and admin see it; the unrelated user does not.
assert.True(t, h.fileListIDs(aliceToken)[fileID], "owner should see own file")
assert.True(t, h.fileListIDs(adminToken)[fileID], "admin should see all files")
assert.False(t, h.fileListIDs(bobToken)[fileID], "private file must not appear for a non-owner")
// Making it public reveals it to everyone.
resp = h.doJSON("PATCH", "/files/"+fileID, map[string]any{"is_public": true}, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
assert.True(t, h.fileListIDs(bobToken)[fileID], "public file should be visible to all")
// Private again → hidden; an explicit view grant reveals it.
resp = h.doJSON("PATCH", "/files/"+fileID, map[string]any{"is_public": false}, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
assert.False(t, h.fileListIDs(bobToken)[fileID])
resp = h.doJSON("PUT", "/acl/file/"+fileID, map[string]any{
"permissions": []map[string]any{{"user_id": bobID, "can_view": true, "can_edit": false}},
}, aliceToken)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
assert.True(t, h.fileListIDs(bobToken)[fileID], "granted file should be visible in the listing")
}
// TestPoolOperationsRequireACL verifies that a non-owner cannot view or modify
// another user's private pool's contents.
func TestPoolOperationsRequireACL(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
adminToken := h.login("admin", "admin")
resp := h.doJSON("POST", "/users", map[string]any{"name": "alice", "password": "alicepass"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
resp = h.doJSON("POST", "/users", map[string]any{"name": "bob", "password": "bobpass"}, adminToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
aliceToken := h.login("alice", "alicepass")
bobToken := h.login("bob", "bobpass")
file := h.uploadJPEG(aliceToken, "f.jpg")
fileID := file["id"].(string)
resp = h.doJSON("POST", "/pools", map[string]any{"name": "alice pool", "is_public": false}, aliceToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var pool map[string]any
resp.decode(t, &pool)
poolID := pool["id"].(string)
resp = h.doJSON("POST", "/pools/"+poolID+"/files", map[string]any{"file_ids": []string{fileID}}, aliceToken)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
// Bob cannot view the pool, list its files, or mutate its membership.
for _, c := range []struct {
method, path string
body any
}{
{"GET", "/pools/" + poolID, nil},
{"GET", "/pools/" + poolID + "/files", nil},
{"POST", "/pools/" + poolID + "/files", map[string]any{"file_ids": []string{fileID}}},
{"POST", "/pools/" + poolID + "/files/remove", map[string]any{"file_ids": []string{fileID}}},
{"PUT", "/pools/" + poolID + "/files/reorder", map[string]any{"file_ids": []string{fileID}}},
} {
resp = h.doJSON(c.method, c.path, c.body, bobToken)
assert.Equal(t, http.StatusForbidden, resp.StatusCode, "%s %s: %s", c.method, c.path, resp)
}
// The owner can still list the pool's files.
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, aliceToken)
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// ---------------------------------------------------------------------------
// Test helpers
// ---------------------------------------------------------------------------
// minimalJPEG returns the bytes of a small solid-white JPEG produced by the
// standard library encoder. Encoding is deterministic, so every call yields
// byte-identical output — two uploads therefore hash to the same perceptual
// hash (a solid image dHashes to 0), which is what the duplicate test relies on.
//
// It is encoded (rather than a hand-written byte literal) so the bytes decode
// cleanly through image.Decode: that is the same path FileService.Upload takes
// to compute the perceptual hash, and a fixture that only passes MIME sniffing
// but fails to fully decode would leave phash NULL and silently break dedup.
func minimalJPEG() []byte {
img := image.NewGray(image.Rect(0, 0, 16, 16))
for i := range img.Pix {
img.Pix[i] = 0xff // white
}
var buf bytes.Buffer
if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 75}); err != nil {
panic("minimalJPEG: encode: " + err.Error())
}
return buf.Bytes()
}
// replaceDSNDatabase returns a copy of dsn with the dbname parameter replaced.
// Handles both key=value libpq-style strings and postgres:// URLs.
func replaceDSNDatabase(dsn, newDB string) string {
// key=value style: replace dbname=xxx or append if absent.
if !strings.Contains(dsn, "://") {
const key = "dbname="
if idx := strings.Index(dsn, key); idx >= 0 {
end := strings.IndexByte(dsn[idx+len(key):], ' ')
if end < 0 {
return dsn[:idx] + key + newDB
}
return dsn[:idx] + key + newDB + dsn[idx+len(key)+end:]
}
return dsn + " dbname=" + newDB
}
// URL style (e.g. postgres://user:pass@host:port/dbname?opts): the database
// is the URL path. CI passes this form via TANABATA_TEST_ADMIN_DSN, so it
// must swap the path to point the suite at its per-run database.
if u, err := url.Parse(dsn); err == nil {
u.Path = "/" + newDB
return u.String()
}
return dsn
}
// freePort returns an available TCP port on localhost.
func freePort() int {
l, _ := net.Listen("tcp", ":0")
defer l.Close()
return l.Addr().(*net.TCPAddr).Port
}
// writeFile writes content to a temp file and returns its path.
func writeFile(t *testing.T, dir, name string, content []byte) string {
t.Helper()
path := filepath.Join(dir, name)
require.NoError(t, os.WriteFile(path, content, 0o644))
return path
}
// suppress unused-import warnings for helpers kept for future use.
var (
_ = freePort
_ = writeFile
)
// dupListResponse decodes GET /files/duplicates.
type dupListResponse struct {
Items []struct {
Files []struct {
ID string `json:"id"`
Tags []struct {
ID string `json:"id"`
} `json:"tags"`
} `json:"files"`
Distances []struct {
A string `json:"a"`
B string `json:"b"`
Distance int `json:"distance"`
} `json:"distances"`
} `json:"items"`
Total int `json:"total"`
}
// TestDuplicateDetection exercises the full duplicate lifecycle: perceptual hashes
// are computed on upload, a rescan builds the pairs table, the cluster surfaces,
// a field-by-field merge unions tags and trashes the discarded file, and a
// dismissal hides a pair permanently (surviving a re-rescan).
//
// minimalJPEG() is a 1×1 image, so every upload hashes identically — in a fresh
// database any two uploads form one duplicate pair, which keeps this deterministic.
func TestDuplicateDetection(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
ctx := context.Background()
admin := h.login("admin", "admin")
// --- two uploads => one duplicate pair after a rescan ---------------------
f1 := h.uploadJPEG(admin, "a.jpg")["id"].(string)
f2 := h.uploadJPEG(admin, "b.jpg")["id"].(string)
// Tag f2 so the merge has something to union onto the survivor.
resp := h.doJSON("POST", "/tags", map[string]any{"name": "kept", "is_public": true}, admin)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var tag map[string]any
resp.decode(t, &tag)
tagID := tag["id"].(string)
resp = h.doJSON("PUT", "/files/"+f2+"/tags", map[string]any{"tag_ids": []string{tagID}}, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
require.NoError(t, h.dupSvc.Rescan(ctx, nil))
resp = h.doJSON("GET", "/files/duplicates", nil, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var list dupListResponse
resp.decode(t, &list)
require.Equal(t, 1, list.Total, "expected one duplicate cluster: %s", resp)
require.Len(t, list.Items, 1)
require.Len(t, list.Items[0].Files, 2)
// The pair's stored distance rides along; identical 1×1 uploads are distance 0.
require.Len(t, list.Items[0].Distances, 1, "the pair's distance should be reported")
assert.Equal(t, 0, list.Items[0].Distances[0].Distance)
// --- resolve: keep f1, union tags from f2, trash f2 ----------------------
resp = h.doJSON("POST", "/files/duplicates/resolve", map[string]any{
"keep": f1,
"discard": f2,
"fields": map[string]any{"tags": "both"},
"delete_discarded": true,
}, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var survivor struct {
ID string `json:"id"`
Tags []struct {
ID string `json:"id"`
} `json:"tags"`
}
resp.decode(t, &survivor)
assert.Equal(t, f1, survivor.ID)
require.Len(t, survivor.Tags, 1, "survivor should have inherited the discarded file's tag")
assert.Equal(t, tagID, survivor.Tags[0].ID)
// f2 is now trashed, so the pair drops out of the duplicates view.
resp = h.doJSON("GET", "/files/duplicates", nil, admin)
resp.decode(t, &list)
assert.Equal(t, 0, list.Total, "resolved pair should no longer surface: %s", resp)
// --- dismiss: a new pair, hidden and staying hidden across a rescan -------
f3 := h.uploadJPEG(admin, "c.jpg")["id"].(string)
require.NoError(t, h.dupSvc.Rescan(ctx, nil))
resp = h.doJSON("GET", "/files/duplicates", nil, admin)
resp.decode(t, &list)
require.Equal(t, 1, list.Total, "f1 and f3 should now form a cluster: %s", resp)
resp = h.doJSON("POST", "/files/duplicates/dismiss", map[string]any{
"file_id_a": f1, "file_id_b": f3,
}, admin)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
resp = h.doJSON("GET", "/files/duplicates", nil, admin)
resp.decode(t, &list)
assert.Equal(t, 0, list.Total, "dismissed pair should be hidden")
// A rescan re-finds the pair but the dismissal still hides it.
require.NoError(t, h.dupSvc.Rescan(ctx, nil))
resp = h.doJSON("GET", "/files/duplicates", nil, admin)
resp.decode(t, &list)
assert.Equal(t, 0, list.Total, "dismissal must survive a rescan")
}
// TestPoolAutomaticSort exercises per-pool file ordering: the default manual
// order, switching to automatic sorts (by name and by creation), that an
// auto-sorted pool rejects manual reordering, and that keyset paging honours the
// active sort.
func TestPoolAutomaticSort(t *testing.T) {
if testing.Short() {
t.Skip("skipping integration test in short mode")
}
h := setupSuite(t)
admin := h.login("admin", "admin")
// Upload three files with distinct names in a non-alphabetical order.
idB := h.uploadJPEG(admin, "b.jpg")["id"].(string)
idA := h.uploadJPEG(admin, "a.jpg")["id"].(string)
idC := h.uploadJPEG(admin, "c.jpg")["id"].(string)
// "created" order is UUID (byte) order — lexicographic on the canonical string.
createdAsc := []string{idA, idB, idC}
sort.Strings(createdAsc)
createdDesc := []string{createdAsc[2], createdAsc[1], createdAsc[0]}
// New pool defaults to manual ordering.
resp := h.doJSON("POST", "/pools", map[string]any{"name": "sortpool"}, admin)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
var pool map[string]any
resp.decode(t, &pool)
poolID := pool["id"].(string)
require.Equal(t, "manual", pool["sort_key"])
// Add in upload order (b, a, c) → that becomes the manual position order.
resp = h.doJSON("POST", "/pools/"+poolID+"/files",
map[string]any{"file_ids": []string{idB, idA, idC}}, admin)
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
listIDs := func() []string {
resp := h.doJSON("GET", "/pools/"+poolID+"/files", nil, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
}
resp.decode(t, &page)
ids := make([]string, len(page.Items))
for i, it := range page.Items {
ids[i] = it.ID
}
return ids
}
setSort := func(key, order string) {
body := map[string]any{"sort_key": key}
if order != "" {
body["sort_order"] = order
}
resp := h.doJSON("PATCH", "/pools/"+poolID, body, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
}
// Manual = insertion order.
require.Equal(t, []string{idB, idA, idC}, listIDs())
// By name ascending → a, b, c.
setSort("original_name", "asc")
require.Equal(t, []string{idA, idB, idC}, listIDs())
// By creation descending → reverse UUID order.
setSort("created", "desc")
require.Equal(t, createdDesc, listIDs())
// Reordering an auto-sorted pool is rejected.
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder",
map[string]any{"file_ids": []string{idC, idB, idA}}, admin)
require.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.String())
// Back to manual → reordering works again and sticks.
setSort("manual", "")
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder",
map[string]any{"file_ids": []string{idC, idB, idA}}, admin)
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
require.Equal(t, []string{idC, idB, idA}, listIDs())
// Keyset paging (limit 1) under an automatic sort returns the full sequence.
setSort("original_name", "asc")
var paged []string
cursor := ""
for i := 0; i < 10; i++ {
url := "/pools/" + poolID + "/files?limit=1"
if cursor != "" {
url += "&cursor=" + cursor
}
resp := h.doJSON("GET", url, nil, admin)
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
var page struct {
Items []struct {
ID string `json:"id"`
} `json:"items"`
NextCursor *string `json:"next_cursor"`
}
resp.decode(t, &page)
for _, it := range page.Items {
paged = append(paged, it.ID)
}
if page.NextCursor == nil {
break
}
cursor = *page.NextCursor
}
require.Equal(t, []string{idA, idB, idC}, paged)
}