DB 接入塊 0-5 上主幹後的收尾工作,讓 DB-on 模式可真人使用 + 補齊功能與測試。 OIDC / pairing FK 修復(接 DB 上線必要): - 新建 internal/user package(User + Store + InMemory + Postgres);OIDC callback 驗證 id_token 成功後 fail-closed upsert users(sub 直接當 users.id,MC sub 為 UUID) - pairing exchange 雲端自建 device(不動 local-tool)+ 同 tx 綁 session token; 自建 device 空 serial 寫 NULL(避免撞 partial unique) - device.SaveTx / session.CreateTx 新增 tx-aware 版本 B4 model metadata: - 轉檔 result 的 analysis_info(input_shape/classes/framework)串進 model: converter_client → flow → adapter → model.Model → PG → ModelResponse DTO - input_shape 優先用陣列、後備四維組 NCHW、缺一不亂組;全 optional 防禦性 - 前端詳細頁顯示(另 repo);轉檔端串接交接檔 b4-converter-handoff.md nginx healthz(部署層): - 新增 /healthz/deep 轉發 backend(ping PG+Redis、down 回 503)給 LB - 修掉 default_server return 444 短路 bug(docker healthcheck 長期 unhealthy 真因) storage error 統一映射(不洩漏 storage 後端細節)。 測試:補 internal/api(storage/errors handler)、cmd/api-server(seed/adapter)、 internal/db(redis)、relay/session 弱處,含 testcontainers integration。 DB 接入相關 package 真環境覆蓋達 88-94%。全程 Reviewer 審查 + 130 真 PG/Redis dbtest 綠。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
325 lines
11 KiB
Go
325 lines
11 KiB
Go
package api
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"net/http"
|
||
"net/http/httptest"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/model"
|
||
"visiona-backend/internal/storage"
|
||
)
|
||
|
||
// 建一個 in-memory fixture(storage + model repo)給 models_test 用。
|
||
func newModelsFixture(t *testing.T) (*gin.Engine, *model.InMemoryRepository, *storage.LocalFSStore) {
|
||
t.Helper()
|
||
|
||
dir := t.TempDir()
|
||
st, err := storage.NewLocalFSStore(dir, "http://api/storage", "test-secret")
|
||
require.NoError(t, err)
|
||
|
||
repo := model.NewInMemoryRepository()
|
||
|
||
r := gin.New()
|
||
r.Use(RequestIDMiddleware())
|
||
// Phase 0.7 security fix C1:injectStaticUserContext 顯式注入 UserContext。
|
||
r.Use(injectStaticUserContext("demo-user", ""))
|
||
g := r.Group("/api")
|
||
registerModelRoutes(g, Deps{
|
||
ModelRepo: repo,
|
||
Storage: st,
|
||
MaxUploadSizeMB: 10,
|
||
})
|
||
return r, repo, st
|
||
}
|
||
|
||
// TestModelsInit_OK 驗證 init 能成功:建立 pending 紀錄並回 upload_url。
|
||
func TestModelsInit_OK(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
body := strings.NewReader(`{"name":"m1","file_size":1024}`)
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/models/init", body)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||
|
||
var sb SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
|
||
data := sb.Data.(map[string]any)
|
||
|
||
modelID, _ := data["model_id"].(string)
|
||
require.NotEmpty(t, modelID)
|
||
assert.Contains(t, data["upload_url"].(string), "signature=")
|
||
|
||
// Repo 中應已有 pending 紀錄(UploadedAt == nil)
|
||
m, err := repo.Get(context.Background(), modelID)
|
||
require.NoError(t, err)
|
||
assert.Nil(t, m.UploadedAt)
|
||
assert.Equal(t, int64(1024), m.FileSize)
|
||
}
|
||
|
||
// TestModelsInit_NameMissing 驗證沒 name 回 400。
|
||
func TestModelsInit_NameMissing(t *testing.T) {
|
||
r, _, _ := newModelsFixture(t)
|
||
|
||
body := strings.NewReader(`{"file_size":1024}`)
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/models/init", body)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodeValidationFailed)
|
||
}
|
||
|
||
// TestModelsInit_TooLarge 驗證超過限制回 413。
|
||
func TestModelsInit_TooLarge(t *testing.T) {
|
||
r, _, _ := newModelsFixture(t)
|
||
// MaxUploadSizeMB=10,送 11MB
|
||
body := strings.NewReader(`{"name":"big","file_size":11534336}`) // 11 MB
|
||
w := httptest.NewRecorder()
|
||
req := httptest.NewRequest(http.MethodPost, "/api/models/init", body)
|
||
req.Header.Set("Content-Type", "application/json")
|
||
r.ServeHTTP(w, req)
|
||
|
||
assert.Equal(t, http.StatusRequestEntityTooLarge, w.Code)
|
||
assert.Contains(t, w.Body.String(), ErrCodePayloadTooLarge)
|
||
}
|
||
|
||
// TestModelsFinalize_FileNotUploaded 驗證 finalize 在沒實際 PUT 前回 400。
|
||
func TestModelsFinalize_FileNotUploaded(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
// 先塞一筆 pending model(沒實際檔案)
|
||
now := time.Now().UTC()
|
||
m := &model.Model{
|
||
ID: "mdl-1",
|
||
OwnerUserID: "demo-user",
|
||
Name: "x",
|
||
FileSize: 100,
|
||
StorageKey: "models/demo-user/mdl-1.nef",
|
||
Source: model.SourceUploaded,
|
||
CreatedAt: now,
|
||
}
|
||
require.NoError(t, repo.Save(context.Background(), m))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/models/mdl-1/finalize", nil))
|
||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||
assert.Contains(t, w.Body.String(), "file not uploaded")
|
||
}
|
||
|
||
// TestModelsFinalize_SizeMismatch 驗證實際檔案大小對不上 file_size 回 400。
|
||
func TestModelsFinalize_SizeMismatch(t *testing.T) {
|
||
r, repo, st := newModelsFixture(t)
|
||
|
||
// 塞 pending model(宣稱 100 bytes)
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-2",
|
||
OwnerUserID: "demo-user",
|
||
Name: "x",
|
||
FileSize: 100,
|
||
StorageKey: "models/demo-user/mdl-2.nef",
|
||
Source: model.SourceUploaded,
|
||
}))
|
||
// 實際檔案寫 10 bytes(Size 不符)
|
||
require.NoError(t, st.Put(context.Background(), "models/demo-user/mdl-2.nef",
|
||
strings.NewReader("0123456789"), 10, nil))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/models/mdl-2/finalize", nil))
|
||
assert.Equal(t, http.StatusBadRequest, w.Code)
|
||
assert.Contains(t, w.Body.String(), "size mismatch")
|
||
}
|
||
|
||
// TestModelsFinalize_OK 驗證 happy path:檔案已存在、size 對得上,標 ready。
|
||
func TestModelsFinalize_OK(t *testing.T) {
|
||
r, repo, st := newModelsFixture(t)
|
||
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-3",
|
||
OwnerUserID: "demo-user",
|
||
Name: "x",
|
||
FileSize: 5,
|
||
StorageKey: "models/demo-user/mdl-3.nef",
|
||
Source: model.SourceUploaded,
|
||
}))
|
||
require.NoError(t, st.Put(context.Background(), "models/demo-user/mdl-3.nef",
|
||
strings.NewReader("hello"), 5, nil))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/models/mdl-3/finalize", nil))
|
||
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||
|
||
// Repo 中應已 UploadedAt 被設
|
||
m, err := repo.Get(context.Background(), "mdl-3")
|
||
require.NoError(t, err)
|
||
assert.NotNil(t, m.UploadedAt)
|
||
}
|
||
|
||
// TestModelsDelete_NotOwner 驗證非 owner 不能刪。
|
||
func TestModelsDelete_NotOwner(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
// 塞一個「別人」的 model
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-other",
|
||
OwnerUserID: "other-user",
|
||
Name: "x",
|
||
Source: model.SourceUploaded,
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodDelete, "/api/models/mdl-other", nil))
|
||
assert.Equal(t, http.StatusForbidden, w.Code)
|
||
}
|
||
|
||
// TestModelsGet_HTTPResponseCarriesMetadata 驗證 B4 metadata 鏈路的最後序列化點:
|
||
// GET /api/models/:id 的 HTTP JSON response 真的含 input_shape / classes / framework。
|
||
//
|
||
// 這是先前測試的盲區——舊測試只測到 model.Model 落地(dbtest)為止、沒測最外層 HTTP
|
||
// JSON。此測試直接 call handler → 解析 response JSON → 斷言三欄都在裡面且值正確。
|
||
func TestModelsGet_HTTPResponseCarriesMetadata(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
now := time.Now().UTC()
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-meta",
|
||
OwnerUserID: "demo-user",
|
||
Name: "metamodel",
|
||
Source: model.SourceConverted,
|
||
InputShape: []int{1, 3, 224, 224},
|
||
Classes: []string{"cat", "dog"},
|
||
Framework: "onnx",
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/mdl-meta", nil))
|
||
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||
|
||
var sb SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
|
||
data, ok := sb.Data.(map[string]any)
|
||
require.True(t, ok, "data should be an object")
|
||
|
||
// input_shape 必須在最外層 HTTP JSON 裡(snake_case,前端 normalize 讀得到)。
|
||
rawShape, present := data["input_shape"]
|
||
require.True(t, present, "input_shape must be present in HTTP JSON response; body=%s", w.Body.String())
|
||
shape, ok := rawShape.([]any)
|
||
require.True(t, ok)
|
||
require.Len(t, shape, 4)
|
||
assert.EqualValues(t, 1, shape[0])
|
||
assert.EqualValues(t, 3, shape[1])
|
||
assert.EqualValues(t, 224, shape[2])
|
||
assert.EqualValues(t, 224, shape[3])
|
||
|
||
// classes / framework 同樣要帶到。
|
||
classes, ok := data["classes"].([]any)
|
||
require.True(t, ok, "classes must be present; body=%s", w.Body.String())
|
||
require.Len(t, classes, 2)
|
||
assert.Equal(t, "cat", classes[0])
|
||
assert.Equal(t, "dog", classes[1])
|
||
|
||
assert.Equal(t, "onnx", data["framework"])
|
||
}
|
||
|
||
// TestModelsGet_OmitsMetadataWhenAbsent 驗證 omitempty:無 metadata 的 model(如上傳類 /
|
||
// 舊 model)回應不含 input_shape / classes / framework,不破壞既有回應結構。
|
||
func TestModelsGet_OmitsMetadataWhenAbsent(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-nometa",
|
||
OwnerUserID: "demo-user",
|
||
Name: "plain",
|
||
Source: model.SourceUploaded,
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models/mdl-nometa", nil))
|
||
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||
|
||
var sb SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
|
||
data, ok := sb.Data.(map[string]any)
|
||
require.True(t, ok)
|
||
|
||
_, hasShape := data["input_shape"]
|
||
_, hasClasses := data["classes"]
|
||
_, hasFramework := data["framework"]
|
||
assert.False(t, hasShape, "input_shape should be omitted when empty")
|
||
assert.False(t, hasClasses, "classes should be omitted when empty")
|
||
assert.False(t, hasFramework, "framework should be omitted when empty")
|
||
}
|
||
|
||
// TestModelsList_HTTPResponseCarriesMetadata 驗證 list endpoint(GET /api/models)的
|
||
// HTTP JSON 也帶 input_shape(列表頁雖非 B4 主顯示處,仍確認鏈路一致)。
|
||
func TestModelsList_HTTPResponseCarriesMetadata(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "mdl-list-meta",
|
||
OwnerUserID: "demo-user",
|
||
Name: "listed",
|
||
Source: model.SourceConverted,
|
||
InputShape: []int{1, 28, 28},
|
||
Framework: "tflite",
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil))
|
||
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
|
||
|
||
var sb SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
|
||
arr, ok := sb.Data.([]any)
|
||
require.True(t, ok)
|
||
require.Len(t, arr, 1)
|
||
first := arr[0].(map[string]any)
|
||
|
||
rawShape, present := first["input_shape"]
|
||
require.True(t, present, "input_shape must be present in list response; body=%s", w.Body.String())
|
||
shape, ok := rawShape.([]any)
|
||
require.True(t, ok)
|
||
require.Len(t, shape, 3)
|
||
assert.EqualValues(t, 1, shape[0])
|
||
assert.EqualValues(t, 28, shape[1])
|
||
assert.EqualValues(t, 28, shape[2])
|
||
assert.Equal(t, "tflite", first["framework"])
|
||
}
|
||
|
||
// TestModelsList_FiltersByOwner 驗證 list 只回當前 user 的模型。
|
||
func TestModelsList_FiltersByOwner(t *testing.T) {
|
||
r, repo, _ := newModelsFixture(t)
|
||
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "my", OwnerUserID: "demo-user", Name: "mine", Source: model.SourceUploaded,
|
||
}))
|
||
require.NoError(t, repo.Save(context.Background(), &model.Model{
|
||
ID: "other", OwnerUserID: "other-user", Name: "theirs", Source: model.SourceUploaded,
|
||
}))
|
||
|
||
w := httptest.NewRecorder()
|
||
r.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/api/models", nil))
|
||
require.Equal(t, http.StatusOK, w.Code)
|
||
|
||
var sb SuccessBody
|
||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &sb))
|
||
arr, ok := sb.Data.([]any)
|
||
require.True(t, ok)
|
||
assert.Len(t, arr, 1, "只應看到自己的 model")
|
||
first := arr[0].(map[string]any)
|
||
assert.Equal(t, "my", first["id"])
|
||
}
|