//go:build dbtest // Postgres cascade unpair 的真 DB 整合測試(DB 接入塊 5.2 / 5.5)。 // // build tag `dbtest`:只在帶 `-tags=dbtest`(需要 Docker / testcontainers)時編譯/執行。 // 預設 `go test ./...`(無 Docker)不觸碰本檔,維持綠燈。 // // 執行: // // go test -tags=dbtest ./internal/api/... // # 無本機 Docker 時,Orchestrator 在 130 補跑: // DOCKER_HOST=tcp://192.168.0.130:2375 TESTCONTAINERS_RYUK_DISABLED=true \ // go test -tags=dbtest ./internal/api/... // // 涵蓋: // - cascade 成功:刪 device → 同 tx 撤 pairing + session token(database.md §6)。 // - rollback 原子性:cascade 中途失敗 → device 軟刪也回滾(device 仍存在、token 未撤)。 // - device 不存在 → device.ErrNotFound、不撤任何 token。 package api import ( "context" "errors" "testing" "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "visiona-backend/internal/auth" "visiona-backend/internal/db" "visiona-backend/internal/db/testsupport" "visiona-backend/internal/device" ) // pgUnpairFixture 建一個已就緒的 Postgres 環境:device + 綁該 device 的 pairing/session token。 func pgUnpairFixture(t *testing.T) ( tdb *testsupport.TestDB, devRepo *device.PostgresRepository, pairing *auth.PostgresPairingStore, sessions *auth.PostgresSessionTokenStore, owner, deviceID, pairingPlain, sessionPlain string, ) { t.Helper() ctx := context.Background() tdb = testsupport.SetupTestDB(t) tdb.Truncate(t, "pairing_tokens", "session_tokens", "devices", "users") owner = tdb.EnsureDemoUser(t) devRepo = device.NewPostgresRepository(tdb.Pool) pairing = auth.NewPostgresPairingStore(tdb.Pool) sessions = auth.NewPostgresSessionTokenStore(tdb.Pool) deviceID = tdb.InsertDevice(t, "", owner) pPlain, _, err := pairing.Create(ctx, owner, 15*time.Minute) require.NoError(t, err) require.NoError(t, pairing.MarkUsed(ctx, pPlain, deviceID)) pairingPlain = pPlain sPlain, _, err := sessions.Create(ctx, owner, deviceID, "", auth.SessionTokenTTL) require.NoError(t, err) sessionPlain = sPlain return } // TestPGUnpair_CascadeSuccess 驗證 cascade 成功:device 軟刪 + 兩張 token 表撤銷,單一交易落地。 func TestPGUnpair_CascadeSuccess(t *testing.T) { ctx := context.Background() tdb, devRepo, pairing, sessions, _, deviceID, pPlain, sPlain := pgUnpairFixture(t) unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, sessions, nil) res, err := unpairer.Unpair(ctx, deviceID) require.NoError(t, err) assert.Equal(t, 1, res.PairingRevoked) assert.Equal(t, 1, res.SessionRevoked) // device 已軟刪 _, err = devRepo.Get(ctx, deviceID) assert.ErrorIs(t, err, device.ErrNotFound) // pairing token 已撤 _, err = pairing.Validate(ctx, pPlain) assert.ErrorIs(t, err, auth.ErrTokenRevoked) // session token 已撤 _, err = sessions.Get(ctx, sPlain) assert.ErrorIs(t, err, auth.ErrTokenRevoked) } // failingRevoker 是會回 error 的 token revoker,用來觸發 cascade 中途失敗、驗證 rollback。 type failingRevoker struct{} func (failingRevoker) RevokeByDeviceTx(ctx context.Context, q db.Querier, deviceID string) (int, error) { return 0, errors.New("simulated revoke failure") } // TestPGUnpair_RollbackOnCascadeFailure 驗證 cascade 中途失敗 → device 軟刪也整筆回滾。 // // 用 failingRevoker 取代 session token revoker:device 在 tx 內已軟刪、pairing 已撤,但 session // 撤銷回 error → WithTx rollback,最終 device 仍未刪、pairing token 也未撤(整筆原子)。 func TestPGUnpair_RollbackOnCascadeFailure(t *testing.T) { ctx := context.Background() tdb, devRepo, pairing, _, _, deviceID, pPlain, _ := pgUnpairFixture(t) unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, failingRevoker{}, nil) _, err := unpairer.Unpair(ctx, deviceID) require.Error(t, err) // device 仍存在(軟刪被 rollback) d, getErr := devRepo.Get(ctx, deviceID) require.NoError(t, getErr, "device 應因 rollback 仍存在") assert.Nil(t, d.DeletedAt) // pairing token 也未撤(同一交易 rollback)。 // fixture 已 MarkUsed(cascade 撤銷靠 WHERE device_id,token 須先綁 device), // 故不能用 Validate 驗——used token 必回 ErrTokenUsed。直接查 revoked_at 是否仍為 NULL。 var pairingRevokedAt *time.Time qErr := tdb.Pool.QueryRow(ctx, `SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`, auth.HashToken(pPlain)).Scan(&pairingRevokedAt) require.NoError(t, qErr) assert.Nil(t, pairingRevokedAt, "pairing token 應因 rollback 未被撤銷") } // TestPGUnpair_DeviceNotFound 驗證刪不存在 device → device.ErrNotFound、不撤任何 token。 func TestPGUnpair_DeviceNotFound(t *testing.T) { ctx := context.Background() tdb, devRepo, pairing, sessions, _, _, pPlain, sPlain := pgUnpairFixture(t) unpairer := NewPostgresDeviceUnpairer(tdb.Pool, devRepo, pairing, sessions, nil) _, err := unpairer.Unpair(ctx, "00000000-0000-0000-0000-0000000000ff") assert.ErrorIs(t, err, device.ErrNotFound) // 原 device 的 token 不受影響(沒被誤撤)。 // pairing token fixture 已 MarkUsed,不能用 Validate(必回 ErrTokenUsed);直接查 revoked_at 仍為 NULL。 var pairingRevokedAt *time.Time qErr := tdb.Pool.QueryRow(ctx, `SELECT revoked_at FROM pairing_tokens WHERE token_hash = $1`, auth.HashToken(pPlain)).Scan(&pairingRevokedAt) require.NoError(t, qErr) assert.Nil(t, pairingRevokedAt, "不存在 device 的 unpair 不應誤撤 pairing token") // session token 無 used 概念,未撤銷時 Get 正常回 NoError,沿用原斷言。 _, err = sessions.Get(ctx, sPlain) require.NoError(t, err) }