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>
139 lines
5.0 KiB
Go
139 lines
5.0 KiB
Go
//go:build dbtest
|
||
|
||
// PostgresStore(user)的真 DB 整合測試(DB-on FK 收尾,問題 #1)。
|
||
//
|
||
// build tag `dbtest`:只在帶 `-tags=dbtest` 時編譯/執行(需要 Docker / testcontainers)。
|
||
// 預設 `go test ./...`(無 Docker)不會觸碰本檔,維持綠燈。
|
||
//
|
||
// 執行:
|
||
//
|
||
// go test -tags=dbtest ./internal/user/...
|
||
// # 無本機 Docker 時,Orchestrator 在 130 補跑:
|
||
// DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \
|
||
// go test -tags=dbtest ./internal/user/...
|
||
//
|
||
// 涵蓋:
|
||
// - Upsert 新建 + Get round-trip(id=sub UUID、email、name、roles)
|
||
// - Upsert 同 id 再寫:更新 email/name/roles、保留 created_at
|
||
// - email 大小寫不敏感唯一(uq_users_email_lower):兩個不同 sub 共用同 email(差大小寫)→ 撞 unique
|
||
// - Get 不存在 → ErrNotFound
|
||
// - roles nil → 寫空陣列(NOT NULL DEFAULT '{}')round-trip 回空 slice
|
||
package user
|
||
|
||
import (
|
||
"context"
|
||
"testing"
|
||
|
||
"github.com/google/uuid"
|
||
"github.com/jackc/pgx/v5/pgconn"
|
||
"github.com/stretchr/testify/assert"
|
||
"github.com/stretchr/testify/require"
|
||
|
||
"visiona-backend/internal/db/testsupport"
|
||
)
|
||
|
||
// newPGUserStore 啟動一次性測試 DB、truncate users,回傳 store。
|
||
func newPGUserStore(t *testing.T) (*PostgresStore, *testsupport.TestDB) {
|
||
t.Helper()
|
||
tdb := testsupport.SetupTestDB(t)
|
||
tdb.Truncate(t, "users")
|
||
return NewPostgresStore(tdb.Pool), tdb
|
||
}
|
||
|
||
func TestPGUser_UpsertAndGet(t *testing.T) {
|
||
ctx := context.Background()
|
||
s, _ := newPGUserStore(t)
|
||
|
||
id := uuid.NewString() // 模擬 OIDC sub(UUID 格式,D1-B)
|
||
in := &User{ID: id, Email: "Alice@Example.com", Name: "Alice", Roles: []string{"admin", "user"}}
|
||
require.NoError(t, s.Upsert(ctx, in))
|
||
|
||
got, err := s.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, id, got.ID)
|
||
assert.Equal(t, "Alice@Example.com", got.Email)
|
||
assert.Equal(t, "Alice", got.Name)
|
||
assert.Equal(t, []string{"admin", "user"}, got.Roles)
|
||
assert.False(t, got.CreatedAt.IsZero())
|
||
assert.False(t, got.UpdatedAt.IsZero())
|
||
}
|
||
|
||
func TestPGUser_Upsert_UpdatesAndPreservesCreatedAt(t *testing.T) {
|
||
ctx := context.Background()
|
||
s, _ := newPGUserStore(t)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "old@example.com", Name: "Old"}))
|
||
first, err := s.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
origCreated := first.CreatedAt
|
||
|
||
require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "new@example.com", Name: "New", Roles: []string{"r"}}))
|
||
second, err := s.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
|
||
assert.Equal(t, "new@example.com", second.Email, "email 應更新")
|
||
assert.Equal(t, "New", second.Name, "name 應更新")
|
||
assert.Equal(t, []string{"r"}, second.Roles, "roles 應更新")
|
||
assert.Equal(t, origCreated, second.CreatedAt, "created_at 應保留(ON CONFLICT 不動 created_at)")
|
||
assert.True(t, second.UpdatedAt.After(origCreated) || second.UpdatedAt.Equal(origCreated),
|
||
"updated_at 應 >= created_at")
|
||
}
|
||
|
||
// TestPGUser_Upsert_EmailUniqueCaseInsensitive 驗證 uq_users_email_lower:
|
||
// 兩個不同 sub 共用同 email(差大小寫)→ 第二筆撞 lower(email) unique(23505)。
|
||
//
|
||
// 這是 D1-B 下的已知資料異常邊界(雛形原樣回錯、不靜默吞)。
|
||
func TestPGUser_Upsert_EmailUniqueCaseInsensitive(t *testing.T) {
|
||
ctx := context.Background()
|
||
s, _ := newPGUserStore(t)
|
||
|
||
require.NoError(t, s.Upsert(ctx, &User{ID: uuid.NewString(), Email: "dup@example.com"}))
|
||
|
||
err := s.Upsert(ctx, &User{ID: uuid.NewString(), Email: "DUP@example.com"})
|
||
require.Error(t, err, "不同 sub 共用同 email(差大小寫)應撞 uq_users_email_lower")
|
||
var pgErr *pgconn.PgError
|
||
if assert.ErrorAs(t, err, &pgErr) {
|
||
assert.Equal(t, "23505", pgErr.Code, "應為 unique_violation")
|
||
}
|
||
}
|
||
|
||
func TestPGUser_Get_NotFound(t *testing.T) {
|
||
ctx := context.Background()
|
||
s, _ := newPGUserStore(t)
|
||
|
||
_, err := s.Get(ctx, uuid.NewString())
|
||
assert.ErrorIs(t, err, ErrNotFound)
|
||
}
|
||
|
||
// TestPGUser_Upsert_NilRolesEmptyArray 驗證 roles nil → 寫空陣列 round-trip 回空/ nil。
|
||
func TestPGUser_Upsert_NilRolesEmptyArray(t *testing.T) {
|
||
ctx := context.Background()
|
||
s, _ := newPGUserStore(t)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "noroles@example.com"}))
|
||
got, err := s.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Empty(t, got.Roles, "roles 預設應為空(NOT NULL DEFAULT '{}')")
|
||
}
|
||
|
||
// TestPGUser_Upsert_NameNullable 驗證 name 空字串 → 寫 NULL → 掃回空字串。
|
||
func TestPGUser_Upsert_NameNullable(t *testing.T) {
|
||
ctx := context.Background()
|
||
s, tdb := newPGUserStore(t)
|
||
|
||
id := uuid.NewString()
|
||
require.NoError(t, s.Upsert(ctx, &User{ID: id, Email: "noname@example.com"}))
|
||
|
||
// 直接查 DB 確認 name 欄位是 NULL(非空字串)。
|
||
var nameIsNull bool
|
||
require.NoError(t, tdb.Pool.QueryRow(ctx,
|
||
`SELECT name IS NULL FROM users WHERE id = $1`, id).Scan(&nameIsNull))
|
||
assert.True(t, nameIsNull, "空 name 應寫成 NULL")
|
||
|
||
got, err := s.Get(ctx, id)
|
||
require.NoError(t, err)
|
||
assert.Equal(t, "", got.Name, "NULL name 應掃回空字串")
|
||
}
|