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"]) }