jim800121chen 9ab1a11ea4 feat(models): 補 7 個預設模型(B8)+ 詳細頁下載按鈕(B5)
# B8 預設模型(簡單版:寫死常數 + 打包 + 不簽 token)
模型庫「預設模型」原本實際是空的(舊 seed 是 demo 假資料、production 不啟用)。
補 7 個公用預設模型(kl520×4 + kl720×3,來源 local-tool models.json):
- metadata 寫死成 Go 常數(presets.go),不進 DB → 公用、不可刪、無 seed 重複
- 7 個 .nef 打包進 image(assets/preset-models/,~61MB;Dockerfile COPY)
- download 走 visionA 自己(簡單版不簽 token,preset 公用本不需授權):
  download handler 三分支 preset→visionA URL / converted→FAA / uploaded→501
- 新 GET /preset-models/*filepath 靜態 serve(無 auth、Content-Disposition
  attachment 用 mime.FormatMediaType、path-traversal 防禦)
- list/get 含 preset(對所有人可見)

# B5 詳細頁下載按鈕 + preset 可下載
- model-detail-client.tsx 補下載按鈕(行為對齊列表卡片)
- isModelDownloadable 加 preset(source==="preset" → true)
- normalizeModelSummary 對 target_chip toLowerCase(修 preset 大寫 KL520
  在小寫晶片篩選下被隱藏的 bug;收斂所有來源大小寫)

# 測試
backend:presets/preset 下載三分支/靜態 serve/path-traversal/條件對稱 全綠
frontend:詳細頁下載鈕 + preset 可下載 + target_chip 大小寫篩選回歸 38 PASS
Reviewer 兩輪通過(Major-1 + Minor-1/2 修畢、複審 0 問題)

# backlog(未做的優化)
preset 下載改 HMAC presigned / preset .nef 改放 FAA 減 image / metadata 後台可管理

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 07:38:25 +08:00

341 lines
12 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 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 fixturestorage + 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 C1injectStaticUserContext 顯式注入 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 bytesSize 不符)
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 endpointGET /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)
// B8list = 7 preset + 1 user model = 8 筆。找出 user model 那筆(不假設位置)。
require.Len(t, arr, 8)
var first map[string]any
for _, item := range arr {
m := item.(map[string]any)
if m["id"] == "mdl-list-meta" {
first = m
break
}
}
require.NotNil(t, first, "user model must be in list; body=%s", w.Body.String())
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)
// B8list = 7 preset公用+ 只有自己的那 1 筆 user model = 8 筆。
// 不應看到 other-user 的 modelpreset 對所有人可見。
require.Len(t, arr, 8, "7 preset + 1 own model")
ids := map[string]bool{}
for _, item := range arr {
ids[item.(map[string]any)["id"].(string)] = true
}
assert.True(t, ids["my"], "應看到自己的 model")
assert.False(t, ids["other"], "不應看到別人的 model")
assert.True(t, ids["kl520-yolov5-detection"], "preset 應對所有人可見")
}