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