jim800121chen cabbdde495 feat(visionA-backend): DB 接入後續 — OIDC/pairing FK 收尾 + B4 metadata + nginx healthz + 補測試
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>
2026-06-21 06:36:35 +08:00

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