Artifact and media metadata were previously reconstructed from job result refs and JSON sidecar files. This change regularizes metadata into SQLite, keeps filesystem blobs in place, and preserves backward compatibility via lazy fallback and backfill from existing job refs and JSON metadata. Constraint: Blob storage remains on the local filesystem in this phase Rejected: Migrate blobs into SQLite | larger scope and worse operational profile for current media sizes Rejected: Hard cutover without fallback | unsafe for historical data already on the test server Confidence: medium Scope-risk: moderate Directive: Treat SQLite as the metadata source of truth; JSON sidecars are compatibility fallback only Tested: go test ./...; deployed to test server 101.42.99.35; verified /v1/artifacts, /v1/artifacts/:id, signed media URL 200, unsigned content 401 Not-tested: Full historical backfill sweep over all existing artifact rows under production-sized data volume
205 lines
6.2 KiB
Go
205 lines
6.2 KiB
Go
package server
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"io"
|
|
"mime/multipart"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestMediaUploadGetAndContent(t *testing.T) {
|
|
cfg := Config{
|
|
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"),
|
|
SkillhubDir: makeEmptySkillhub(t),
|
|
SessionSecret: "test-secret",
|
|
}
|
|
server, err := NewWithConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWithConfig: %v", err)
|
|
}
|
|
srv := httptest.NewServer(server.Handler())
|
|
defer srv.Close()
|
|
server.cfg.PublicBaseURL = srv.URL
|
|
|
|
sessionToken, _, ok, err := server.store.createSession("sk-media-user")
|
|
if err != nil {
|
|
t.Fatalf("createSession: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected session creation to succeed")
|
|
}
|
|
|
|
imageBytes := tinyPNG(t)
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
if err := writer.WriteField("project_id", "proj_media_demo"); err != nil {
|
|
t.Fatalf("write project_id field: %v", err)
|
|
}
|
|
if err := writer.WriteField("visibility", "public"); err != nil {
|
|
t.Fatalf("write visibility field: %v", err)
|
|
}
|
|
part, err := writer.CreateFormFile("file", "poster.png")
|
|
if err != nil {
|
|
t.Fatalf("create form file: %v", err)
|
|
}
|
|
if _, err := part.Write(imageBytes); err != nil {
|
|
t.Fatalf("write form file: %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("close multipart writer: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/media/upload", &body)
|
|
req.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
server.Handler().ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("expected status 201, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data media `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode upload response: %v body=%s", err, rec.Body.String())
|
|
}
|
|
if !envelope.OK {
|
|
t.Fatalf("expected ok upload response, got %s", rec.Body.String())
|
|
}
|
|
if envelope.Data.ID == "" {
|
|
t.Fatalf("expected media id, got %#v", envelope.Data)
|
|
}
|
|
if envelope.Data.URL == "" {
|
|
t.Fatalf("expected stable url, got %#v", envelope.Data)
|
|
}
|
|
|
|
getReq := httptest.NewRequest(http.MethodGet, "/v1/media/"+envelope.Data.ID, nil)
|
|
getReq.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
getRec := httptest.NewRecorder()
|
|
server.Handler().ServeHTTP(getRec, getReq)
|
|
if getRec.Code != http.StatusOK {
|
|
t.Fatalf("expected media get 200, got %d body=%s", getRec.Code, getRec.Body.String())
|
|
}
|
|
if strings.TrimSpace(envelope.Data.URL) == "" {
|
|
t.Fatalf("expected signed media url, got %#v", envelope.Data)
|
|
}
|
|
|
|
contentResp, err := http.Get(envelope.Data.URL)
|
|
if err != nil {
|
|
t.Fatalf("GET media content: %v", err)
|
|
}
|
|
defer contentResp.Body.Close()
|
|
if contentResp.StatusCode != http.StatusOK {
|
|
t.Fatalf("expected media content 200, got %d", contentResp.StatusCode)
|
|
}
|
|
content, err := io.ReadAll(contentResp.Body)
|
|
if err != nil {
|
|
t.Fatalf("read content body: %v", err)
|
|
}
|
|
if !bytes.Equal(content, imageBytes) {
|
|
t.Fatal("expected media content bytes to match uploaded file")
|
|
}
|
|
|
|
unsignedResp, err := http.Get(strings.Split(envelope.Data.URL, "?")[0])
|
|
if err != nil {
|
|
t.Fatalf("GET unsigned media content: %v", err)
|
|
}
|
|
defer unsignedResp.Body.Close()
|
|
if unsignedResp.StatusCode != http.StatusUnauthorized {
|
|
t.Fatalf("expected unsigned media content 401, got %d", unsignedResp.StatusCode)
|
|
}
|
|
|
|
otherSession, _, ok, err := server.store.createSession("sk-media-user-other")
|
|
if err != nil {
|
|
t.Fatalf("createSession other: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected second session creation to succeed")
|
|
}
|
|
otherReq := httptest.NewRequest(http.MethodGet, "/v1/media/"+envelope.Data.ID, nil)
|
|
otherReq.Header.Set("Authorization", "Bearer "+otherSession)
|
|
otherRec := httptest.NewRecorder()
|
|
server.Handler().ServeHTTP(otherRec, otherReq)
|
|
if otherRec.Code != http.StatusNotFound {
|
|
t.Fatalf("expected foreign media metadata 404, got %d body=%s", otherRec.Code, otherRec.Body.String())
|
|
}
|
|
}
|
|
|
|
func TestMediaGetFallsBackToSQLiteWhenJSONMetaIsMissing(t *testing.T) {
|
|
cfg := Config{
|
|
SQLitePath: filepath.Join(t.TempDir(), "popiart.db"),
|
|
SkillhubDir: makeEmptySkillhub(t),
|
|
SessionSecret: "test-secret",
|
|
}
|
|
server, err := NewWithConfig(cfg)
|
|
if err != nil {
|
|
t.Fatalf("NewWithConfig: %v", err)
|
|
}
|
|
srv := httptest.NewServer(server.Handler())
|
|
defer srv.Close()
|
|
server.cfg.PublicBaseURL = srv.URL
|
|
|
|
sessionToken, _, ok, err := server.store.createSession("sk-media-sqlite")
|
|
if err != nil {
|
|
t.Fatalf("createSession: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("expected session creation to succeed")
|
|
}
|
|
|
|
imageBytes := tinyPNG(t)
|
|
var body bytes.Buffer
|
|
writer := multipart.NewWriter(&body)
|
|
part, err := writer.CreateFormFile("file", "sqlite-only.png")
|
|
if err != nil {
|
|
t.Fatalf("create form file: %v", err)
|
|
}
|
|
if _, err := part.Write(imageBytes); err != nil {
|
|
t.Fatalf("write form file: %v", err)
|
|
}
|
|
if err := writer.Close(); err != nil {
|
|
t.Fatalf("close multipart writer: %v", err)
|
|
}
|
|
|
|
req := httptest.NewRequest(http.MethodPost, "/v1/media/upload", &body)
|
|
req.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
req.Header.Set("Content-Type", writer.FormDataContentType())
|
|
rec := httptest.NewRecorder()
|
|
server.Handler().ServeHTTP(rec, req)
|
|
if rec.Code != http.StatusCreated {
|
|
t.Fatalf("expected media upload 201, got %d body=%s", rec.Code, rec.Body.String())
|
|
}
|
|
|
|
var envelope struct {
|
|
OK bool `json:"ok"`
|
|
Data media `json:"data"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &envelope); err != nil {
|
|
t.Fatalf("decode media upload response: %v body=%s", err, rec.Body.String())
|
|
}
|
|
if !envelope.OK || envelope.Data.ID == "" {
|
|
t.Fatalf("expected media upload payload, got %s", rec.Body.String())
|
|
}
|
|
|
|
if err := os.Remove(mediaMetaPath(server.cfg, envelope.Data.ID)); err != nil {
|
|
t.Fatalf("remove media json meta: %v", err)
|
|
}
|
|
|
|
getReq := httptest.NewRequest(http.MethodGet, "/v1/media/"+envelope.Data.ID, nil)
|
|
getReq.Header.Set("Authorization", "Bearer "+sessionToken)
|
|
getRec := httptest.NewRecorder()
|
|
server.Handler().ServeHTTP(getRec, getReq)
|
|
if getRec.Code != http.StatusOK {
|
|
t.Fatalf("expected media get 200 from sqlite, got %d body=%s", getRec.Code, getRec.Body.String())
|
|
}
|
|
}
|