fix(backend): fix file upload and integration test suite
- Make data.files.exif column nullable (was NOT NULL but service passes nil
for files without EXIF data, causing a constraint violation on upload)
- FileRepo.Create: include id in INSERT so disk storage path and DB record
share the same UUID (previously DB generated its own UUID, causing a mismatch)
- Integration test: use correct filter DSL format {t=<uuid>} instead of tag:<uuid>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0784605267
commit
071829a79e
@ -302,17 +302,18 @@ const fileSelectCTE = `
|
|||||||
// Create
|
// Create
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
// Create inserts a new file record. The MIME type is resolved from
|
// Create inserts a new file record using the ID already set on f.
|
||||||
// f.MIMEType (name string) via a subquery; the DB generates the UUID v7 id.
|
// The MIME type is resolved from f.MIMEType (name string) via a subquery.
|
||||||
func (r *FileRepo) Create(ctx context.Context, f *domain.File) (*domain.File, error) {
|
func (r *FileRepo) Create(ctx context.Context, f *domain.File) (*domain.File, error) {
|
||||||
const sqlStr = `
|
const sqlStr = `
|
||||||
WITH r AS (
|
WITH r AS (
|
||||||
INSERT INTO data.files
|
INSERT INTO data.files
|
||||||
(original_name, mime_id, content_datetime, notes, metadata, exif, phash, creator_id, is_public)
|
(id, original_name, mime_id, content_datetime, notes, metadata, exif, phash, creator_id, is_public)
|
||||||
VALUES (
|
VALUES (
|
||||||
$1,
|
$1,
|
||||||
(SELECT id FROM core.mime_types WHERE name = $2),
|
$2,
|
||||||
$3, $4, $5, $6, $7, $8, $9
|
(SELECT id FROM core.mime_types WHERE name = $3),
|
||||||
|
$4, $5, $6, $7, $8, $9, $10
|
||||||
)
|
)
|
||||||
RETURNING id, original_name, mime_id, content_datetime, notes,
|
RETURNING id, original_name, mime_id, content_datetime, notes,
|
||||||
metadata, exif, phash, creator_id, is_public, is_deleted
|
metadata, exif, phash, creator_id, is_public, is_deleted
|
||||||
@ -320,7 +321,7 @@ func (r *FileRepo) Create(ctx context.Context, f *domain.File) (*domain.File, er
|
|||||||
|
|
||||||
q := connOrTx(ctx, r.pool)
|
q := connOrTx(ctx, r.pool)
|
||||||
rows, err := q.Query(ctx, sqlStr,
|
rows, err := q.Query(ctx, sqlStr,
|
||||||
f.OriginalName, f.MIMEType, f.ContentDatetime,
|
f.ID, f.OriginalName, f.MIMEType, f.ContentDatetime,
|
||||||
f.Notes, f.Metadata, f.EXIF, f.PHash,
|
f.Notes, f.Metadata, f.EXIF, f.PHash,
|
||||||
f.CreatorID, f.IsPublic,
|
f.CreatorID, f.IsPublic,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,8 +1,13 @@
|
|||||||
// Package integration contains end-to-end tests that start a real HTTP server
|
// Package integration contains end-to-end tests that start a real HTTP server
|
||||||
// against a disposable PostgreSQL container spun up via testcontainers-go.
|
// against a disposable PostgreSQL database created on the fly.
|
||||||
//
|
//
|
||||||
// Run with:
|
// 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
|
// go test -v -timeout 120s tanabata/backend/internal/integration
|
||||||
package integration
|
package integration
|
||||||
|
|
||||||
@ -22,13 +27,10 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/jackc/pgx/v5/stdlib"
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
"github.com/pressly/goose/v3"
|
"github.com/pressly/goose/v3"
|
||||||
"github.com/testcontainers/testcontainers-go"
|
|
||||||
tcpostgres "github.com/testcontainers/testcontainers-go/modules/postgres"
|
|
||||||
"github.com/testcontainers/testcontainers-go/wait"
|
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
@ -39,6 +41,10 @@ import (
|
|||||||
"tanabata/backend/migrations"
|
"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
|
// Test harness
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -49,32 +55,45 @@ type harness struct {
|
|||||||
client *http.Client
|
client *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
// setupSuite spins up a Postgres container, runs migrations, wires the full
|
// setupSuite creates an ephemeral database, runs migrations, wires the full
|
||||||
// service graph, and returns an httptest.Server + bare http.Client.
|
// service graph into an httptest.Server, and registers cleanup.
|
||||||
func setupSuite(t *testing.T) *harness {
|
func setupSuite(t *testing.T) *harness {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
|
|
||||||
// --- Postgres container --------------------------------------------------
|
// --- Create an isolated test database ------------------------------------
|
||||||
pgCtr, err := tcpostgres.Run(ctx,
|
adminDSN := os.Getenv("TANABATA_TEST_ADMIN_DSN")
|
||||||
"postgres:16-alpine",
|
if adminDSN == "" {
|
||||||
tcpostgres.WithDatabase("tanabata_test"),
|
adminDSN = defaultAdminDSN
|
||||||
tcpostgres.WithUsername("test"),
|
}
|
||||||
tcpostgres.WithPassword("test"),
|
|
||||||
testcontainers.WithWaitStrategy(
|
|
||||||
wait.ForLog("database system is ready to accept connections").
|
|
||||||
WithOccurrence(2).
|
|
||||||
WithStartupTimeout(60*time.Second),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
t.Cleanup(func() { _ = pgCtr.Terminate(ctx) })
|
|
||||||
|
|
||||||
dsn, err := pgCtr.ConnectionString(ctx, "sslmode=disable")
|
// 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)
|
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 ----------------------------------------------------------
|
// --- Migrations ----------------------------------------------------------
|
||||||
pool, err := pgxpool.New(ctx, dsn)
|
pool, err := pgxpool.New(ctx, testDSN)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
t.Cleanup(pool.Close)
|
t.Cleanup(pool.Close)
|
||||||
|
|
||||||
@ -145,11 +164,27 @@ func setupSuite(t *testing.T) *harness {
|
|||||||
// HTTP helpers
|
// 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 {
|
func (h *harness) url(path string) string {
|
||||||
return h.server.URL + "/api/v1" + path
|
return h.server.URL + "/api/v1" + path
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *harness) do(method, path string, body io.Reader, token string, contentType string) *http.Response {
|
func (h *harness) do(method, path string, body io.Reader, token string, contentType string) *testResponse {
|
||||||
h.t.Helper()
|
h.t.Helper()
|
||||||
req, err := http.NewRequest(method, h.url(path), body)
|
req, err := http.NewRequest(method, h.url(path), body)
|
||||||
require.NoError(h.t, err)
|
require.NoError(h.t, err)
|
||||||
@ -159,12 +194,14 @@ func (h *harness) do(method, path string, body io.Reader, token string, contentT
|
|||||||
if contentType != "" {
|
if contentType != "" {
|
||||||
req.Header.Set("Content-Type", contentType)
|
req.Header.Set("Content-Type", contentType)
|
||||||
}
|
}
|
||||||
resp, err := h.client.Do(req)
|
httpResp, err := h.client.Do(req)
|
||||||
require.NoError(h.t, err)
|
require.NoError(h.t, err)
|
||||||
return resp
|
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) *http.Response {
|
func (h *harness) doJSON(method, path string, payload any, token string) *testResponse {
|
||||||
h.t.Helper()
|
h.t.Helper()
|
||||||
var buf io.Reader
|
var buf io.Reader
|
||||||
if payload != nil {
|
if payload != nil {
|
||||||
@ -175,29 +212,17 @@ func (h *harness) doJSON(method, path string, payload any, token string) *http.R
|
|||||||
return h.do(method, path, buf, token, "application/json")
|
return h.do(method, path, buf, token, "application/json")
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeJSON(t *testing.T, resp *http.Response, dst any) {
|
|
||||||
t.Helper()
|
|
||||||
defer resp.Body.Close()
|
|
||||||
require.NoError(t, json.NewDecoder(resp.Body).Decode(dst))
|
|
||||||
}
|
|
||||||
|
|
||||||
func bodyString(resp *http.Response) string {
|
|
||||||
b, _ := io.ReadAll(resp.Body)
|
|
||||||
resp.Body.Close()
|
|
||||||
return string(b)
|
|
||||||
}
|
|
||||||
|
|
||||||
// login posts credentials and returns an access token.
|
// login posts credentials and returns an access token.
|
||||||
func (h *harness) login(name, password string) string {
|
func (h *harness) login(name, password string) string {
|
||||||
h.t.Helper()
|
h.t.Helper()
|
||||||
resp := h.doJSON("POST", "/auth/login", map[string]string{
|
resp := h.doJSON("POST", "/auth/login", map[string]string{
|
||||||
"name": name, "password": password,
|
"name": name, "password": password,
|
||||||
}, "")
|
}, "")
|
||||||
require.Equal(h.t, http.StatusOK, resp.StatusCode, "login failed: "+bodyString(resp))
|
require.Equal(h.t, http.StatusOK, resp.StatusCode, "login failed: %s", resp)
|
||||||
var out struct {
|
var out struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
}
|
}
|
||||||
decodeJSON(h.t, resp, &out)
|
resp.decode(h.t, &out)
|
||||||
require.NotEmpty(h.t, out.AccessToken)
|
require.NotEmpty(h.t, out.AccessToken)
|
||||||
return out.AccessToken
|
return out.AccessToken
|
||||||
}
|
}
|
||||||
@ -206,22 +231,19 @@ func (h *harness) login(name, password string) string {
|
|||||||
func (h *harness) uploadJPEG(token, originalName string) map[string]any {
|
func (h *harness) uploadJPEG(token, originalName string) map[string]any {
|
||||||
h.t.Helper()
|
h.t.Helper()
|
||||||
|
|
||||||
// Minimal valid JPEG (1×1 red pixel).
|
|
||||||
jpegBytes := minimalJPEG()
|
|
||||||
|
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
mw := multipart.NewWriter(&buf)
|
mw := multipart.NewWriter(&buf)
|
||||||
fw, err := mw.CreateFormFile("file", originalName)
|
fw, err := mw.CreateFormFile("file", originalName)
|
||||||
require.NoError(h.t, err)
|
require.NoError(h.t, err)
|
||||||
_, err = fw.Write(jpegBytes)
|
_, err = fw.Write(minimalJPEG())
|
||||||
require.NoError(h.t, err)
|
require.NoError(h.t, err)
|
||||||
require.NoError(h.t, mw.Close())
|
require.NoError(h.t, mw.Close())
|
||||||
|
|
||||||
resp := h.do("POST", "/files", &buf, token, mw.FormDataContentType())
|
resp := h.do("POST", "/files", &buf, token, mw.FormDataContentType())
|
||||||
require.Equal(h.t, http.StatusCreated, resp.StatusCode, "upload failed: "+bodyString(resp))
|
require.Equal(h.t, http.StatusCreated, resp.StatusCode, "upload failed: %s", resp)
|
||||||
|
|
||||||
var out map[string]any
|
var out map[string]any
|
||||||
decodeJSON(h.t, resp, &out)
|
resp.decode(h.t, &out)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -247,16 +269,16 @@ func TestFullFlow(t *testing.T) {
|
|||||||
resp := h.doJSON("POST", "/users", map[string]any{
|
resp := h.doJSON("POST", "/users", map[string]any{
|
||||||
"name": "alice", "password": "alicepass", "can_create": true,
|
"name": "alice", "password": "alicepass", "can_create": true,
|
||||||
}, adminToken)
|
}, adminToken)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
var aliceUser map[string]any
|
var aliceUser map[string]any
|
||||||
decodeJSON(t, resp, &aliceUser)
|
resp.decode(t, &aliceUser)
|
||||||
assert.Equal(t, "alice", aliceUser["name"])
|
assert.Equal(t, "alice", aliceUser["name"])
|
||||||
|
|
||||||
// Create a second regular user for ACL testing.
|
// Create a second regular user for ACL testing.
|
||||||
resp = h.doJSON("POST", "/users", map[string]any{
|
resp = h.doJSON("POST", "/users", map[string]any{
|
||||||
"name": "bob", "password": "bobpass", "can_create": true,
|
"name": "bob", "password": "bobpass", "can_create": true,
|
||||||
}, adminToken)
|
}, adminToken)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 3. Log in as alice
|
// 3. Log in as alice
|
||||||
@ -279,21 +301,21 @@ func TestFullFlow(t *testing.T) {
|
|||||||
resp = h.doJSON("POST", "/tags", map[string]any{
|
resp = h.doJSON("POST", "/tags", map[string]any{
|
||||||
"name": "nature", "is_public": true,
|
"name": "nature", "is_public": true,
|
||||||
}, aliceToken)
|
}, aliceToken)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
var tagObj map[string]any
|
var tagObj map[string]any
|
||||||
decodeJSON(t, resp, &tagObj)
|
resp.decode(t, &tagObj)
|
||||||
tagID := tagObj["id"].(string)
|
tagID := tagObj["id"].(string)
|
||||||
|
|
||||||
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
|
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
|
||||||
"tag_ids": []string{tagID},
|
"tag_ids": []string{tagID},
|
||||||
}, aliceToken)
|
}, aliceToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// Verify tag is returned with the file.
|
// Verify tag is returned with the file.
|
||||||
resp = h.doJSON("GET", "/files/"+fileID, nil, aliceToken)
|
resp = h.doJSON("GET", "/files/"+fileID, nil, aliceToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var fileWithTags map[string]any
|
var fileWithTags map[string]any
|
||||||
decodeJSON(t, resp, &fileWithTags)
|
resp.decode(t, &fileWithTags)
|
||||||
tags := fileWithTags["tags"].([]any)
|
tags := fileWithTags["tags"].([]any)
|
||||||
require.Len(t, tags, 1)
|
require.Len(t, tags, 1)
|
||||||
assert.Equal(t, "nature", tags[0].(map[string]any)["name"])
|
assert.Equal(t, "nature", tags[0].(map[string]any)["name"])
|
||||||
@ -301,10 +323,10 @@ func TestFullFlow(t *testing.T) {
|
|||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 6. Filter files by tag
|
// 6. Filter files by tag
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
resp = h.doJSON("GET", "/files?filter=tag:"+tagID, nil, aliceToken)
|
resp = h.doJSON("GET", "/files?filter=%7Bt%3D"+tagID+"%7D", nil, aliceToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var filePage map[string]any
|
var filePage map[string]any
|
||||||
decodeJSON(t, resp, &filePage)
|
resp.decode(t, &filePage)
|
||||||
items := filePage["items"].([]any)
|
items := filePage["items"].([]any)
|
||||||
require.Len(t, items, 1)
|
require.Len(t, items, 1)
|
||||||
assert.Equal(t, fileID, items[0].(map[string]any)["id"])
|
assert.Equal(t, fileID, items[0].(map[string]any)["id"])
|
||||||
@ -313,7 +335,7 @@ func TestFullFlow(t *testing.T) {
|
|||||||
// 7. ACL — Bob cannot see Alice's private file
|
// 7. ACL — Bob cannot see Alice's private file
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
|
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, bodyString(resp))
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// Grant Bob view access.
|
// Grant Bob view access.
|
||||||
bobUserID := int(aliceUser["id"].(float64)) // alice's id used for reference; get bob's
|
bobUserID := int(aliceUser["id"].(float64)) // alice's id used for reference; get bob's
|
||||||
@ -321,7 +343,7 @@ func TestFullFlow(t *testing.T) {
|
|||||||
resp = h.doJSON("GET", "/users", nil, adminToken)
|
resp = h.doJSON("GET", "/users", nil, adminToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var usersPage map[string]any
|
var usersPage map[string]any
|
||||||
decodeJSON(t, resp, &usersPage)
|
resp.decode(t, &usersPage)
|
||||||
var bobID float64
|
var bobID float64
|
||||||
for _, u := range usersPage["items"].([]any) {
|
for _, u := range usersPage["items"].([]any) {
|
||||||
um := u.(map[string]any)
|
um := u.(map[string]any)
|
||||||
@ -337,11 +359,11 @@ func TestFullFlow(t *testing.T) {
|
|||||||
{"user_id": bobID, "can_view": true, "can_edit": false},
|
{"user_id": bobID, "can_view": true, "can_edit": false},
|
||||||
},
|
},
|
||||||
}, aliceToken)
|
}, aliceToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// Now Bob can view.
|
// Now Bob can view.
|
||||||
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
|
resp = h.doJSON("GET", "/files/"+fileID, nil, bobToken)
|
||||||
assert.Equal(t, http.StatusOK, resp.StatusCode, bodyString(resp))
|
assert.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 8. Create a pool and add the file
|
// 8. Create a pool and add the file
|
||||||
@ -349,29 +371,29 @@ func TestFullFlow(t *testing.T) {
|
|||||||
resp = h.doJSON("POST", "/pools", map[string]any{
|
resp = h.doJSON("POST", "/pools", map[string]any{
|
||||||
"name": "alice's pool", "is_public": false,
|
"name": "alice's pool", "is_public": false,
|
||||||
}, aliceToken)
|
}, aliceToken)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
var poolObj map[string]any
|
var poolObj map[string]any
|
||||||
decodeJSON(t, resp, &poolObj)
|
resp.decode(t, &poolObj)
|
||||||
poolID := poolObj["id"].(string)
|
poolID := poolObj["id"].(string)
|
||||||
assert.Equal(t, "alice's pool", poolObj["name"])
|
assert.Equal(t, "alice's pool", poolObj["name"])
|
||||||
|
|
||||||
resp = h.doJSON("POST", "/pools/"+poolID+"/files", map[string]any{
|
resp = h.doJSON("POST", "/pools/"+poolID+"/files", map[string]any{
|
||||||
"file_ids": []string{fileID},
|
"file_ids": []string{fileID},
|
||||||
}, aliceToken)
|
}, aliceToken)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// Pool file count should now be 1.
|
// Pool file count should now be 1.
|
||||||
resp = h.doJSON("GET", "/pools/"+poolID, nil, aliceToken)
|
resp = h.doJSON("GET", "/pools/"+poolID, nil, aliceToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var poolFull map[string]any
|
var poolFull map[string]any
|
||||||
decodeJSON(t, resp, &poolFull)
|
resp.decode(t, &poolFull)
|
||||||
assert.Equal(t, float64(1), poolFull["file_count"])
|
assert.Equal(t, float64(1), poolFull["file_count"])
|
||||||
|
|
||||||
// List pool files and verify position.
|
// List pool files and verify position.
|
||||||
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, aliceToken)
|
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, aliceToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var poolFiles map[string]any
|
var poolFiles map[string]any
|
||||||
decodeJSON(t, resp, &poolFiles)
|
resp.decode(t, &poolFiles)
|
||||||
poolItems := poolFiles["items"].([]any)
|
poolItems := poolFiles["items"].([]any)
|
||||||
require.Len(t, poolItems, 1)
|
require.Len(t, poolItems, 1)
|
||||||
assert.Equal(t, fileID, poolItems[0].(map[string]any)["id"])
|
assert.Equal(t, fileID, poolItems[0].(map[string]any)["id"])
|
||||||
@ -382,13 +404,13 @@ func TestFullFlow(t *testing.T) {
|
|||||||
|
|
||||||
// Soft-delete the file.
|
// Soft-delete the file.
|
||||||
resp = h.doJSON("DELETE", "/files/"+fileID, nil, aliceToken)
|
resp = h.doJSON("DELETE", "/files/"+fileID, nil, aliceToken)
|
||||||
require.Equal(t, http.StatusNoContent, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// File no longer appears in normal listing.
|
// File no longer appears in normal listing.
|
||||||
resp = h.doJSON("GET", "/files", nil, aliceToken)
|
resp = h.doJSON("GET", "/files", nil, aliceToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var normalPage map[string]any
|
var normalPage map[string]any
|
||||||
decodeJSON(t, resp, &normalPage)
|
resp.decode(t, &normalPage)
|
||||||
normalItems, _ := normalPage["items"].([]any)
|
normalItems, _ := normalPage["items"].([]any)
|
||||||
assert.Len(t, normalItems, 0)
|
assert.Len(t, normalItems, 0)
|
||||||
|
|
||||||
@ -396,7 +418,7 @@ func TestFullFlow(t *testing.T) {
|
|||||||
resp = h.doJSON("GET", "/files?trash=true", nil, aliceToken)
|
resp = h.doJSON("GET", "/files?trash=true", nil, aliceToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var trashPage map[string]any
|
var trashPage map[string]any
|
||||||
decodeJSON(t, resp, &trashPage)
|
resp.decode(t, &trashPage)
|
||||||
trashItems := trashPage["items"].([]any)
|
trashItems := trashPage["items"].([]any)
|
||||||
require.Len(t, trashItems, 1)
|
require.Len(t, trashItems, 1)
|
||||||
assert.Equal(t, fileID, trashItems[0].(map[string]any)["id"])
|
assert.Equal(t, fileID, trashItems[0].(map[string]any)["id"])
|
||||||
@ -404,13 +426,13 @@ func TestFullFlow(t *testing.T) {
|
|||||||
|
|
||||||
// Restore the file.
|
// Restore the file.
|
||||||
resp = h.doJSON("POST", "/files/"+fileID+"/restore", nil, aliceToken)
|
resp = h.doJSON("POST", "/files/"+fileID+"/restore", nil, aliceToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// File is back in normal listing.
|
// File is back in normal listing.
|
||||||
resp = h.doJSON("GET", "/files", nil, aliceToken)
|
resp = h.doJSON("GET", "/files", nil, aliceToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var restoredPage map[string]any
|
var restoredPage map[string]any
|
||||||
decodeJSON(t, resp, &restoredPage)
|
resp.decode(t, &restoredPage)
|
||||||
restoredItems := restoredPage["items"].([]any)
|
restoredItems := restoredPage["items"].([]any)
|
||||||
require.Len(t, restoredItems, 1)
|
require.Len(t, restoredItems, 1)
|
||||||
assert.Equal(t, fileID, restoredItems[0].(map[string]any)["id"])
|
assert.Equal(t, fileID, restoredItems[0].(map[string]any)["id"])
|
||||||
@ -420,11 +442,11 @@ func TestFullFlow(t *testing.T) {
|
|||||||
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
require.Equal(t, http.StatusNoContent, resp.StatusCode)
|
||||||
|
|
||||||
resp = h.doJSON("DELETE", "/files/"+fileID+"/permanent", nil, aliceToken)
|
resp = h.doJSON("DELETE", "/files/"+fileID+"/permanent", nil, aliceToken)
|
||||||
require.Equal(t, http.StatusNoContent, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// File is gone entirely.
|
// File is gone entirely.
|
||||||
resp = h.doJSON("GET", "/files/"+fileID, nil, aliceToken)
|
resp = h.doJSON("GET", "/files/"+fileID, nil, aliceToken)
|
||||||
assert.Equal(t, http.StatusNotFound, resp.StatusCode, bodyString(resp))
|
assert.Equal(t, http.StatusNotFound, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// 10. Audit log records actions (admin only)
|
// 10. Audit log records actions (admin only)
|
||||||
@ -432,13 +454,13 @@ func TestFullFlow(t *testing.T) {
|
|||||||
resp = h.doJSON("GET", "/audit", nil, adminToken)
|
resp = h.doJSON("GET", "/audit", nil, adminToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var auditPage map[string]any
|
var auditPage map[string]any
|
||||||
decodeJSON(t, resp, &auditPage)
|
resp.decode(t, &auditPage)
|
||||||
auditItems := auditPage["items"].([]any)
|
auditItems := auditPage["items"].([]any)
|
||||||
assert.NotEmpty(t, auditItems, "audit log should have entries")
|
assert.NotEmpty(t, auditItems, "audit log should have entries")
|
||||||
|
|
||||||
// Non-admin cannot read the audit log.
|
// Non-admin cannot read the audit log.
|
||||||
resp = h.doJSON("GET", "/audit", nil, aliceToken)
|
resp = h.doJSON("GET", "/audit", nil, aliceToken)
|
||||||
assert.Equal(t, http.StatusForbidden, resp.StatusCode, bodyString(resp))
|
assert.Equal(t, http.StatusForbidden, resp.StatusCode, resp.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@ -460,7 +482,7 @@ func TestBlockedUserCannotLogin(t *testing.T) {
|
|||||||
}, adminToken)
|
}, adminToken)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
var u map[string]any
|
var u map[string]any
|
||||||
decodeJSON(t, resp, &u)
|
resp.decode(t, &u)
|
||||||
userID := u["id"].(float64)
|
userID := u["id"].(float64)
|
||||||
|
|
||||||
// Block charlie.
|
// Block charlie.
|
||||||
@ -495,7 +517,7 @@ func TestPoolReorder(t *testing.T) {
|
|||||||
resp := h.doJSON("POST", "/pools", map[string]any{"name": "reorder-test"}, adminToken)
|
resp := h.doJSON("POST", "/pools", map[string]any{"name": "reorder-test"}, adminToken)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
var pool map[string]any
|
var pool map[string]any
|
||||||
decodeJSON(t, resp, &pool)
|
resp.decode(t, &pool)
|
||||||
poolID := pool["id"].(string)
|
poolID := pool["id"].(string)
|
||||||
|
|
||||||
resp = h.doJSON("POST", "/pools/"+poolID+"/files", map[string]any{
|
resp = h.doJSON("POST", "/pools/"+poolID+"/files", map[string]any{
|
||||||
@ -507,7 +529,7 @@ func TestPoolReorder(t *testing.T) {
|
|||||||
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, adminToken)
|
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, adminToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var page map[string]any
|
var page map[string]any
|
||||||
decodeJSON(t, resp, &page)
|
resp.decode(t, &page)
|
||||||
items := page["items"].([]any)
|
items := page["items"].([]any)
|
||||||
require.Len(t, items, 2)
|
require.Len(t, items, 2)
|
||||||
assert.Equal(t, id1, items[0].(map[string]any)["id"])
|
assert.Equal(t, id1, items[0].(map[string]any)["id"])
|
||||||
@ -517,13 +539,13 @@ func TestPoolReorder(t *testing.T) {
|
|||||||
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder", map[string]any{
|
resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder", map[string]any{
|
||||||
"file_ids": []string{id2, id1},
|
"file_ids": []string{id2, id1},
|
||||||
}, adminToken)
|
}, adminToken)
|
||||||
require.Equal(t, http.StatusNoContent, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// Verify new order.
|
// Verify new order.
|
||||||
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, adminToken)
|
resp = h.doJSON("GET", "/pools/"+poolID+"/files", nil, adminToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var page2 map[string]any
|
var page2 map[string]any
|
||||||
decodeJSON(t, resp, &page2)
|
resp.decode(t, &page2)
|
||||||
items2 := page2["items"].([]any)
|
items2 := page2["items"].([]any)
|
||||||
require.Len(t, items2, 2)
|
require.Len(t, items2, 2)
|
||||||
assert.Equal(t, id2, items2[0].(map[string]any)["id"])
|
assert.Equal(t, id2, items2[0].(map[string]any)["id"])
|
||||||
@ -543,20 +565,20 @@ func TestTagAutoRule(t *testing.T) {
|
|||||||
resp := h.doJSON("POST", "/tags", map[string]any{"name": "outdoor"}, adminToken)
|
resp := h.doJSON("POST", "/tags", map[string]any{"name": "outdoor"}, adminToken)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
var outdoor map[string]any
|
var outdoor map[string]any
|
||||||
decodeJSON(t, resp, &outdoor)
|
resp.decode(t, &outdoor)
|
||||||
outdoorID := outdoor["id"].(string)
|
outdoorID := outdoor["id"].(string)
|
||||||
|
|
||||||
resp = h.doJSON("POST", "/tags", map[string]any{"name": "nature"}, adminToken)
|
resp = h.doJSON("POST", "/tags", map[string]any{"name": "nature"}, adminToken)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
require.Equal(t, http.StatusCreated, resp.StatusCode)
|
||||||
var nature map[string]any
|
var nature map[string]any
|
||||||
decodeJSON(t, resp, &nature)
|
resp.decode(t, &nature)
|
||||||
natureID := nature["id"].(string)
|
natureID := nature["id"].(string)
|
||||||
|
|
||||||
// Create rule: when "outdoor" → also apply "nature".
|
// Create rule: when "outdoor" → also apply "nature".
|
||||||
resp = h.doJSON("POST", "/tags/"+outdoorID+"/rules", map[string]any{
|
resp = h.doJSON("POST", "/tags/"+outdoorID+"/rules", map[string]any{
|
||||||
"then_tag_id": natureID,
|
"then_tag_id": natureID,
|
||||||
}, adminToken)
|
}, adminToken)
|
||||||
require.Equal(t, http.StatusCreated, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// Upload a file and assign only "outdoor".
|
// Upload a file and assign only "outdoor".
|
||||||
file := h.uploadJPEG(adminToken, "park.jpg")
|
file := h.uploadJPEG(adminToken, "park.jpg")
|
||||||
@ -565,13 +587,13 @@ func TestTagAutoRule(t *testing.T) {
|
|||||||
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
|
resp = h.doJSON("PUT", "/files/"+fileID+"/tags", map[string]any{
|
||||||
"tag_ids": []string{outdoorID},
|
"tag_ids": []string{outdoorID},
|
||||||
}, adminToken)
|
}, adminToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode, bodyString(resp))
|
require.Equal(t, http.StatusOK, resp.StatusCode, resp.String())
|
||||||
|
|
||||||
// Both "outdoor" and "nature" should be on the file.
|
// Both "outdoor" and "nature" should be on the file.
|
||||||
resp = h.doJSON("GET", "/files/"+fileID+"/tags", nil, adminToken)
|
resp = h.doJSON("GET", "/files/"+fileID+"/tags", nil, adminToken)
|
||||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
var tagsResp []any
|
var tagsResp []any
|
||||||
decodeJSON(t, resp, &tagsResp)
|
resp.decode(t, &tagsResp)
|
||||||
names := make([]string, 0, len(tagsResp))
|
names := make([]string, 0, len(tagsResp))
|
||||||
for _, tg := range tagsResp {
|
for _, tg := range tagsResp {
|
||||||
names = append(names, tg.(map[string]any)["name"].(string))
|
names = append(names, tg.(map[string]any)["name"].(string))
|
||||||
@ -620,15 +642,33 @@ func minimalJPEG() []byte {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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: not used in our defaults, but handled for completeness.
|
||||||
|
return dsn
|
||||||
|
}
|
||||||
|
|
||||||
// freePort returns an available TCP port on localhost.
|
// freePort returns an available TCP port on localhost.
|
||||||
// Used when a fixed port is needed outside httptest.
|
|
||||||
func freePort() int {
|
func freePort() int {
|
||||||
l, _ := net.Listen("tcp", ":0")
|
l, _ := net.Listen("tcp", ":0")
|
||||||
defer l.Close()
|
defer l.Close()
|
||||||
return l.Addr().(*net.TCPAddr).Port
|
return l.Addr().(*net.TCPAddr).Port
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeFile is a helper used to write a temp file with given content.
|
// writeFile writes content to a temp file and returns its path.
|
||||||
func writeFile(t *testing.T, dir, name string, content []byte) string {
|
func writeFile(t *testing.T, dir, name string, content []byte) string {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
path := filepath.Join(dir, name)
|
path := filepath.Join(dir, name)
|
||||||
@ -636,7 +676,8 @@ func writeFile(t *testing.T, dir, name string, content []byte) string {
|
|||||||
return path
|
return path
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensure unused imports don't cause compile errors in stub helpers.
|
// suppress unused-import warnings for helpers kept for future use.
|
||||||
var _ = strings.Contains
|
var (
|
||||||
var _ = freePort
|
_ = freePort
|
||||||
var _ = writeFile
|
_ = writeFile
|
||||||
|
)
|
||||||
@ -50,7 +50,7 @@ CREATE TABLE data.files (
|
|||||||
content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken)
|
content_datetime timestamptz NOT NULL DEFAULT clock_timestamp(), -- content datetime (e.g. photo taken)
|
||||||
notes text,
|
notes text,
|
||||||
metadata jsonb, -- user-editable key-value data
|
metadata jsonb, -- user-editable key-value data
|
||||||
exif jsonb NOT NULL DEFAULT '{}'::jsonb, -- EXIF data extracted at upload (immutable)
|
exif jsonb, -- EXIF data extracted at upload (immutable)
|
||||||
phash bigint, -- perceptual hash for duplicate detection (future)
|
phash bigint, -- perceptual hash for duplicate detection (future)
|
||||||
creator_id smallint NOT NULL REFERENCES core.users(id)
|
creator_id smallint NOT NULL REFERENCES core.users(id)
|
||||||
ON UPDATE CASCADE ON DELETE RESTRICT,
|
ON UPDATE CASCADE ON DELETE RESTRICT,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user