把 visionA-backend 6 個 in-memory store 接到資料庫持久化,範圍=完整 (PG 全接 + session 接 Redis + 交易韌性)。interface / handler 不動, 只加 DB 實作 + 換 wiring,config 未設 DB 時保留 in-memory fallback。 - 塊 0 基礎建設:pgx/v5 連線池 + DatabaseConfig/RedisConfig + golang-migrate runner(embed)+ cmd/migrate + testcontainers 測試基礎建設 - 塊 1 model → Postgres:array 映射、upsert 保留 CreatedAt、faa_object_key、 三維 filter(owner/chip/source)、soft-delete partial index - 塊 2 device → Postgres:partial unique(已刪 serial 可重註冊)、雙狀態欄位 - 塊 3 token → Postgres:pairing_tokens + session_tokens 分表、token_hash 當 PK - 塊 4 userSession → Redis:idle + absolute 雙 TTL 取代 cleanup goroutine (tunnel session 維持 in-memory,yamux handle 不可序列化) - 塊 5 交易/韌性:WithTx helper + 刪 device cascade 撤銷 token(同 tx 原子) + /healthz ping PG/Redis(fail-fast 503)+ pgx error 統一映射(不洩漏 raw error) 降級策略(fail-fast):PG 掉 → 持久資料 API 回 503;Redis 掉 → session 失敗 不自動 fallback in-memory(避免多機 session 不同步)。 DB:PostgreSQL 14.23(gen_random_uuid 內建、無 citext → email 用 lower() unique index)。每塊經 Reviewer 審查 + 真 PG/Redis testcontainers 全量 dbtest 綠燈, in-memory fallback 未受影響。 docs: 同步更新 database.md(schema/config/migration 清單)+ api-spec.md (409/503 錯誤碼、/healthz 新行為、device unpair cascade)。 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
638 lines
22 KiB
Go
638 lines
22 KiB
Go
package usersession
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"sync"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/alicebob/miniredis/v2"
|
||
"github.com/redis/go-redis/v9"
|
||
)
|
||
|
||
// setupRedisStore 起一個 in-process miniredis(純 Go,不需 docker),回傳:
|
||
// - store:RedisUserSessionStore(idle / absolute TTL 由 caller 指定)
|
||
// - mr:miniredis 實例(可呼叫 FastForward / SetTime / TTL 模擬時間與檢查 key TTL)
|
||
//
|
||
// miniredis 與本 package 的 nowFunc 是「兩個時鐘」:
|
||
// - store 算 absolute deadline 用 nowFunc()
|
||
// - miniredis 算 key TTL 用自己的內部時鐘
|
||
//
|
||
// 測雙 TTL 時兩者都要同步推進(advanceTime helper 一次推兩個)。
|
||
func setupRedisStore(t *testing.T, idle, absolute time.Duration) (*RedisUserSessionStore, *miniredis.Miniredis) {
|
||
t.Helper()
|
||
mr := miniredis.RunT(t)
|
||
client := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||
t.Cleanup(func() { _ = client.Close() })
|
||
store := NewRedisUserSessionStore(client, idle, absolute)
|
||
return store, mr
|
||
}
|
||
|
||
// clock 追蹤「目前已從 base 推進多少」,讓 advanceTime 能對 miniredis 的相對 TTL
|
||
// 做增量 FastForward(FastForward 是「把所有 TTL 減去 duration」,必須用增量、不能用絕對 delta)。
|
||
type clock struct {
|
||
base time.Time
|
||
elapsed time.Duration // 已推進的累計量
|
||
}
|
||
|
||
// advanceTo 把時間推進到 base+delta(delta 必須 >= 目前 elapsed,單調遞增)。
|
||
//
|
||
// 同步推進兩個時鐘:
|
||
// - nowFunc:給 store 算 absolute deadline(app 端邏輯)。
|
||
// - miniredis:用增量 FastForward 推相對 TTL(key 自動過期)+ SetTime 對齊 EXPIREAT 基準。
|
||
func (c *clock) advanceTo(t *testing.T, mr *miniredis.Miniredis, delta time.Duration) time.Time {
|
||
t.Helper()
|
||
if delta < c.elapsed {
|
||
t.Fatalf("clock.advanceTo must be monotonic: delta=%v < elapsed=%v", delta, c.elapsed)
|
||
}
|
||
inc := delta - c.elapsed
|
||
c.elapsed = delta
|
||
newNow := c.base.Add(delta)
|
||
nowFunc = func() time.Time { return newNow }
|
||
mr.SetTime(newNow)
|
||
mr.FastForward(inc) // 相對 TTL 增量推進,<=0 的 key 即過期
|
||
return newNow
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 4.5 對齊既有 in-memory test:Create / Get / Update / Delete 基本行為
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
func TestRedisStore_CreateAndGet(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
ctx := context.Background()
|
||
|
||
sess, err := store.Create(ctx)
|
||
if err != nil {
|
||
t.Fatalf("Create: %v", err)
|
||
}
|
||
if sess.ID == "" {
|
||
t.Fatalf("Create returned empty ID")
|
||
}
|
||
if sess.CreatedAt.IsZero() || sess.LastSeenAt.IsZero() {
|
||
t.Fatalf("CreatedAt/LastSeenAt should be set")
|
||
}
|
||
if !sess.CreatedAt.Equal(sess.LastSeenAt) {
|
||
t.Fatalf("Create: CreatedAt should == LastSeenAt initially, got %v vs %v",
|
||
sess.CreatedAt, sess.LastSeenAt)
|
||
}
|
||
|
||
got, err := store.Get(ctx, sess.ID)
|
||
if err != nil {
|
||
t.Fatalf("Get: %v", err)
|
||
}
|
||
if got.ID != sess.ID {
|
||
t.Fatalf("Get: ID mismatch want=%s got=%s", sess.ID, got.ID)
|
||
}
|
||
}
|
||
|
||
func TestRedisStore_Get_NotFound(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
_, err := store.Get(context.Background(), "no-such-id")
|
||
if !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("expected ErrNoSession, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRedisStore_Get_EmptyID(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
_, err := store.Get(context.Background(), "")
|
||
if !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("expected ErrNoSession for empty id, got %v", err)
|
||
}
|
||
}
|
||
|
||
// Get 回傳的是副本,外部修改不影響 store 內部狀態。
|
||
func TestRedisStore_Get_ReturnsCopy(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
got1, _ := store.Get(ctx, sess.ID)
|
||
got1.Email = "tampered@example.com"
|
||
got1.Extra = map[string]any{"x": "y"}
|
||
|
||
got2, _ := store.Get(ctx, sess.ID)
|
||
if got2.Email == "tampered@example.com" {
|
||
t.Fatalf("Get should return a copy; mutation leaked into store")
|
||
}
|
||
if got2.Extra != nil {
|
||
t.Fatalf("Get should return a copy; Extra map mutation leaked")
|
||
}
|
||
}
|
||
|
||
func TestRedisStore_Update_MovesLastSeenAt_KeepsCreatedAt(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
// 往前推 5 分鐘(同步推進 nowFunc 與 miniredis 時鐘)。
|
||
clk := &clock{base: t0}
|
||
t1 := clk.advanceTo(t, mr, 5*time.Minute)
|
||
|
||
sess.UserID = "user-123"
|
||
sess.Email = "alice@example.com"
|
||
if err := store.Update(ctx, sess); err != nil {
|
||
t.Fatalf("Update: %v", err)
|
||
}
|
||
if !sess.LastSeenAt.Equal(t1) {
|
||
t.Fatalf("Update should reflect new LastSeenAt back to caller, got %v want %v",
|
||
sess.LastSeenAt, t1)
|
||
}
|
||
|
||
got, _ := store.Get(ctx, sess.ID)
|
||
if got.UserID != "user-123" || got.Email != "alice@example.com" {
|
||
t.Fatalf("Update did not persist user fields: %+v", got)
|
||
}
|
||
if !got.LastSeenAt.Equal(t1) {
|
||
t.Fatalf("store LastSeenAt not advanced: got %v want %v", got.LastSeenAt, t1)
|
||
}
|
||
if !got.CreatedAt.Equal(t0) {
|
||
t.Fatalf("Update must not change CreatedAt: got %v want %v", got.CreatedAt, t0)
|
||
}
|
||
}
|
||
|
||
// Update 不可「順便建立」不存在的 session(用 SET XX 達成)。
|
||
func TestRedisStore_Update_NotFound(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
err := store.Update(context.Background(), &Session{ID: "ghost", CreatedAt: nowFunc(), LastSeenAt: nowFunc()})
|
||
if !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("expected ErrNoSession, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRedisStore_Update_NilSession(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
if err := store.Update(context.Background(), nil); !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("expected ErrNoSession for nil, got %v", err)
|
||
}
|
||
}
|
||
|
||
func TestRedisStore_Delete_Idempotent(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
if err := store.Delete(ctx, sess.ID); err != nil {
|
||
t.Fatalf("Delete: %v", err)
|
||
}
|
||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("after Delete, Get should return ErrNoSession, got %v", err)
|
||
}
|
||
// 重複刪同一個 ID 應為 no-op。
|
||
if err := store.Delete(ctx, sess.ID); err != nil {
|
||
t.Fatalf("Delete on missing should be no-op, got %v", err)
|
||
}
|
||
// 刪空 ID 也是 no-op。
|
||
if err := store.Delete(ctx, ""); err != nil {
|
||
t.Fatalf("Delete empty id should be no-op, got %v", err)
|
||
}
|
||
}
|
||
|
||
// Extra map 與所有欄位(含 OIDC pending + token snapshot)round-trip。
|
||
func TestRedisStore_Extra_And_Fields_RoundTrip(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
sess.UserID = "u-9"
|
||
sess.Email = "bob@example.com"
|
||
sess.Name = "Bob"
|
||
sess.OIDCState = "state-xyz"
|
||
sess.OIDCNonce = "nonce-abc"
|
||
sess.OIDCCodeVerifier = "verifier-123"
|
||
sess.AccessToken = "at-secret"
|
||
sess.IDTokenRaw = "idt-raw"
|
||
sess.Extra = map[string]any{
|
||
"return_to": "/dashboard",
|
||
"count": float64(3), // JSON number round-trips as float64
|
||
"flag": true,
|
||
}
|
||
if err := store.Update(ctx, sess); err != nil {
|
||
t.Fatalf("Update: %v", err)
|
||
}
|
||
|
||
got, err := store.Get(ctx, sess.ID)
|
||
if err != nil {
|
||
t.Fatalf("Get: %v", err)
|
||
}
|
||
if got.UserID != "u-9" || got.Email != "bob@example.com" || got.Name != "Bob" {
|
||
t.Fatalf("identity fields mismatch: %+v", got)
|
||
}
|
||
if got.OIDCState != "state-xyz" || got.OIDCNonce != "nonce-abc" || got.OIDCCodeVerifier != "verifier-123" {
|
||
t.Fatalf("OIDC pending fields mismatch: %+v", got)
|
||
}
|
||
if got.AccessToken != "at-secret" || got.IDTokenRaw != "idt-raw" {
|
||
t.Fatalf("token snapshot fields mismatch: %+v", got)
|
||
}
|
||
if got.Extra["return_to"] != "/dashboard" || got.Extra["count"] != float64(3) || got.Extra["flag"] != true {
|
||
t.Fatalf("Extra round-trip mismatch: %+v", got.Extra)
|
||
}
|
||
}
|
||
|
||
// context 取消應被尊重。
|
||
func TestRedisStore_RespectsContext(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
ctx, cancel := context.WithCancel(context.Background())
|
||
cancel()
|
||
|
||
if _, err := store.Create(ctx); !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("Create should respect cancelled ctx, got %v", err)
|
||
}
|
||
if _, err := store.Get(ctx, "x"); !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("Get should respect cancelled ctx, got %v", err)
|
||
}
|
||
if err := store.Update(ctx, &Session{ID: "x"}); !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("Update should respect cancelled ctx, got %v", err)
|
||
}
|
||
if err := store.Delete(ctx, "x"); !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("Delete should respect cancelled ctx, got %v", err)
|
||
}
|
||
if _, err := store.CleanupExpired(ctx, time.Hour, time.Hour); !errors.Is(err, context.Canceled) {
|
||
t.Fatalf("CleanupExpired should respect cancelled ctx, got %v", err)
|
||
}
|
||
}
|
||
|
||
// CleanupExpired 在 Redis 模式是 no-op(靠 key TTL)。
|
||
func TestRedisStore_CleanupExpired_NoOp(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
ctx := context.Background()
|
||
_, _ = store.Create(ctx)
|
||
removed, err := store.CleanupExpired(ctx, time.Hour, time.Hour)
|
||
if err != nil {
|
||
t.Fatalf("CleanupExpired: %v", err)
|
||
}
|
||
if removed != 0 {
|
||
t.Fatalf("Redis CleanupExpired should be no-op, removed=%d", removed)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 4.6 雙 TTL:真 TTL 過期、idle vs absolute(用 miniredis FastForward 模擬時間)
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
// 建立後 key 的 Redis TTL 應為 idle(idle < absolute 時)。
|
||
func TestRedisStore_TTL_OnCreate_IsIdle(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
idle := 24 * time.Hour
|
||
store, mr := setupRedisStore(t, idle, 168*time.Hour)
|
||
mr.SetTime(t0)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
ttl := mr.TTL(redisKey(sess.ID))
|
||
if ttl != idle {
|
||
t.Fatalf("create TTL should be idle(%v), got %v", idle, ttl)
|
||
}
|
||
}
|
||
|
||
// idle 過期:閒置超過 idle → key 被 Redis 自動清掉 → Get 回 ErrNoSession。
|
||
func TestRedisStore_IdleExpiry(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
store, mr := setupRedisStore(t, 1*time.Hour, 168*time.Hour)
|
||
mr.SetTime(t0)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
// 閒置 2 小時(> idle 1h),不做任何 Update。
|
||
clk := &clock{base: t0}
|
||
clk.advanceTo(t, mr, 2*time.Hour)
|
||
|
||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("idle-expired session should be gone, got %v", err)
|
||
}
|
||
}
|
||
|
||
// idle 續期:在 idle 內持續 Update → 不會因 idle 過期。
|
||
func TestRedisStore_IdleRenewedByUpdate(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
store, mr := setupRedisStore(t, 1*time.Hour, 168*time.Hour)
|
||
mr.SetTime(t0)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
// 每 30 分鐘 Update 一次,共推進 2 小時 —— 每次都在 idle(1h) 內續期。
|
||
clk := &clock{base: t0}
|
||
for i := 1; i <= 4; i++ {
|
||
clk.advanceTo(t, mr, time.Duration(i)*30*time.Minute)
|
||
if err := store.Update(ctx, sess); err != nil {
|
||
t.Fatalf("Update #%d at +%dm: %v", i, i*30, err)
|
||
}
|
||
}
|
||
|
||
// 累計 2h 但持續活躍 → 應仍存在。
|
||
if _, err := store.Get(ctx, sess.ID); err != nil {
|
||
t.Fatalf("actively-used session should remain, got %v", err)
|
||
}
|
||
}
|
||
|
||
// absolute 過期:即使持續 Update(idle 永遠新),超過 absolute 後仍應失效。
|
||
func TestRedisStore_AbsoluteExpiry_DespiteActivity(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
// idle 1h、absolute 3h。每 30 分鐘 Update(idle 永不過期),但 3h 後 absolute 應砍掉。
|
||
store, mr := setupRedisStore(t, 1*time.Hour, 3*time.Hour)
|
||
mr.SetTime(t0)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
// 在 absolute 內持續活躍(+30m ~ +180m)。最後一次 Update 落在 absolute deadline 後。
|
||
clk := &clock{base: t0}
|
||
var lastUpdateErr error
|
||
for d := 30 * time.Minute; d <= 210*time.Minute; d += 30 * time.Minute {
|
||
clk.advanceTo(t, mr, d)
|
||
lastUpdateErr = store.Update(ctx, sess)
|
||
}
|
||
// 超過 absolute(3h)後 Update 應回 ErrNoSession(absolute 砍掉)。
|
||
if !errors.Is(lastUpdateErr, ErrNoSession) {
|
||
t.Fatalf("Update past absolute deadline should return ErrNoSession, got %v", lastUpdateErr)
|
||
}
|
||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("absolute-expired session should be gone, got %v", err)
|
||
}
|
||
}
|
||
|
||
// absolute 上限封頂:idle 續期時 key TTL 不可超過「距 absolute deadline 的剩餘」。
|
||
func TestRedisStore_UpdateTTL_CappedByAbsolute(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
// idle 2h、absolute 150m。在 +1h 時 Update(key 仍在,idle 未過):
|
||
// idle 想續成 2h,但距 absolute 只剩 90m,故 TTL 應被封頂為 90m(< idle 2h)。
|
||
store, mr := setupRedisStore(t, 2*time.Hour, 150*time.Minute)
|
||
mr.SetTime(t0)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
clk := &clock{base: t0}
|
||
clk.advanceTo(t, mr, 1*time.Hour)
|
||
if err := store.Update(ctx, sess); err != nil {
|
||
t.Fatalf("Update at +1h: %v", err)
|
||
}
|
||
ttl := mr.TTL(redisKey(sess.ID))
|
||
if ttl != 90*time.Minute {
|
||
t.Fatalf("TTL at +1h should be capped to absolute remaining(90m), got %v", ttl)
|
||
}
|
||
}
|
||
|
||
// key 命名 / 隔離:不同 session 的 key 互不干擾,且都帶 prefix。
|
||
func TestRedisStore_KeyNamingAndIsolation(t *testing.T) {
|
||
store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
ctx := context.Background()
|
||
s1, _ := store.Create(ctx)
|
||
s2, _ := store.Create(ctx)
|
||
|
||
if !mr.Exists(redisKey(s1.ID)) || !mr.Exists(redisKey(s2.ID)) {
|
||
t.Fatalf("both session keys should exist with prefix %q", redisKeyPrefix)
|
||
}
|
||
// 刪 s1 不影響 s2。
|
||
if err := store.Delete(ctx, s1.ID); err != nil {
|
||
t.Fatalf("Delete s1: %v", err)
|
||
}
|
||
if mr.Exists(redisKey(s1.ID)) {
|
||
t.Fatalf("s1 key should be gone")
|
||
}
|
||
if _, err := store.Get(ctx, s2.ID); err != nil {
|
||
t.Fatalf("s2 should remain after deleting s1, got %v", err)
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────────────────────────────────────
|
||
// 4.7 邊界 + 併發
|
||
// ─────────────────────────────────────────────────────────
|
||
|
||
// 永不過期設定:idle=0 且 absolute=0 → key 無 TTL(PERSIST 語意)。
|
||
func TestRedisStore_NeverExpires_WhenBothZero(t *testing.T) {
|
||
store, mr := setupRedisStore(t, 0, 0)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
ttl := mr.TTL(redisKey(sess.ID))
|
||
if ttl != 0 { // miniredis TTL=0 代表無到期時間
|
||
t.Fatalf("with idle=abs=0, key should have no TTL, got %v", ttl)
|
||
}
|
||
// 推 100 天仍在。
|
||
mr.FastForward(100 * 24 * time.Hour)
|
||
if _, err := store.Get(ctx, sess.ID); err != nil {
|
||
t.Fatalf("no-TTL session should remain after 100d, got %v", err)
|
||
}
|
||
}
|
||
|
||
// 只設 absolute(idle=0):key TTL = absolute;過期後消失。
|
||
func TestRedisStore_OnlyAbsolute(t *testing.T) {
|
||
t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC)
|
||
restore := withFrozenNow(t, t0)
|
||
defer restore()
|
||
|
||
store, mr := setupRedisStore(t, 0, 2*time.Hour)
|
||
mr.SetTime(t0)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
if ttl := mr.TTL(redisKey(sess.ID)); ttl != 2*time.Hour {
|
||
t.Fatalf("with idle=0, create TTL should be absolute(2h), got %v", ttl)
|
||
}
|
||
clk := &clock{base: t0}
|
||
clk.advanceTo(t, mr, 3*time.Hour)
|
||
if _, err := store.Get(ctx, sess.ID); !errors.Is(err, ErrNoSession) {
|
||
t.Fatalf("session past absolute should be gone, got %v", err)
|
||
}
|
||
}
|
||
|
||
// 連線中斷:Redis 不可達時 store 方法回非 nil error(不 panic、不靜默成功)。
|
||
func TestRedisStore_ConnectionDown(t *testing.T) {
|
||
store, mr := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
ctx := context.Background()
|
||
sess, _ := store.Create(ctx)
|
||
|
||
mr.Close() // 模擬 Redis 掛掉
|
||
|
||
if _, err := store.Create(ctx); err == nil {
|
||
t.Fatalf("Create should error when redis is down")
|
||
}
|
||
if _, err := store.Get(ctx, sess.ID); err == nil {
|
||
t.Fatalf("Get should error when redis is down")
|
||
}
|
||
if err := store.Update(ctx, sess); err == nil {
|
||
t.Fatalf("Update should error when redis is down")
|
||
}
|
||
if err := store.Delete(ctx, sess.ID); err == nil {
|
||
t.Fatalf("Delete should error when redis is down")
|
||
}
|
||
}
|
||
|
||
// 併發 smoke test(race detector 抓 data race)。
|
||
// 注意:本測試不凍結 nowFunc(避免與其他凍結時間的測試 race;nowFunc 是 package 變數)。
|
||
func TestRedisStore_ConcurrentAccess(t *testing.T) {
|
||
store, _ := setupRedisStore(t, 24*time.Hour, 168*time.Hour)
|
||
ctx := context.Background()
|
||
const goroutines = 16
|
||
const iterations = 30
|
||
|
||
var wg sync.WaitGroup
|
||
wg.Add(goroutines)
|
||
for i := 0; i < goroutines; i++ {
|
||
go func() {
|
||
defer wg.Done()
|
||
for j := 0; j < iterations; j++ {
|
||
sess, err := store.Create(ctx)
|
||
if err != nil {
|
||
t.Errorf("Create: %v", err)
|
||
return
|
||
}
|
||
_, _ = store.Get(ctx, sess.ID)
|
||
sess.UserID = "u"
|
||
_ = store.Update(ctx, sess)
|
||
_ = store.Delete(ctx, sess.ID)
|
||
}
|
||
}()
|
||
}
|
||
wg.Wait()
|
||
}
|
||
|
||
// TestRedisStore_EffectiveTTL_BoundaryDecision 直接斷言 effectiveTTL 的回傳值與
|
||
// save 的「過期 vs PERSIST」決策邊界,**不繞 miniredis**(純決策邏輯,不需 docker)。
|
||
//
|
||
// 守的是 Minor-1 修正:當有 absolute 上限時,TTL 算到 0 ns(now 恰好對齊 deadline)
|
||
// 不能被當成 PERSIST,否則 key 變永不過期、繞過 absolute 上限。
|
||
func TestRedisStore_EffectiveTTL_BoundaryDecision(t *testing.T) {
|
||
base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
||
const (
|
||
idle = 24 * time.Hour
|
||
abs = 168 * time.Hour
|
||
)
|
||
|
||
cases := []struct {
|
||
name string
|
||
idleTTL time.Duration
|
||
absoluteTTL time.Duration
|
||
now time.Time
|
||
createdAt time.Time
|
||
wantTTL time.Duration // effectiveTTL 期望回傳
|
||
wantExpired bool // save 是否應視為已過期(回 ErrSessionExpired)
|
||
wantPersist bool // 是否為合法 PERSIST(ttl==0 但 absoluteTTL==0)
|
||
}{
|
||
{
|
||
// idle + absolute 皆停用 → 合法 PERSIST(ttl 0、不過期)。
|
||
name: "both_disabled_persist",
|
||
idleTTL: 0,
|
||
absoluteTTL: 0,
|
||
now: base,
|
||
createdAt: base,
|
||
wantTTL: 0,
|
||
wantExpired: false,
|
||
wantPersist: true,
|
||
},
|
||
{
|
||
// 有 absolute,now 恰好對齊 deadline(absRemaining == 0 ns)→ 必須視為過期,
|
||
// 不能當 PERSIST。這是 Minor-1 的核心邊界。
|
||
name: "absolute_now_equals_deadline",
|
||
idleTTL: 0,
|
||
absoluteTTL: abs,
|
||
now: base.Add(abs),
|
||
createdAt: base,
|
||
wantTTL: 0,
|
||
wantExpired: true,
|
||
wantPersist: false,
|
||
},
|
||
{
|
||
// deadline 前 1 ns → 還沒到期,TTL = 1 ns。
|
||
name: "absolute_one_ns_before_deadline",
|
||
idleTTL: 0,
|
||
absoluteTTL: abs,
|
||
now: base.Add(abs - 1),
|
||
createdAt: base,
|
||
wantTTL: 1,
|
||
wantExpired: false,
|
||
wantPersist: false,
|
||
},
|
||
{
|
||
// deadline 後 1 ns → 已過期,TTL = -1 ns。
|
||
name: "absolute_one_ns_after_deadline",
|
||
idleTTL: 0,
|
||
absoluteTTL: abs,
|
||
now: base.Add(abs + 1),
|
||
createdAt: base,
|
||
wantTTL: -1,
|
||
wantExpired: true,
|
||
wantPersist: false,
|
||
},
|
||
{
|
||
// idle+absolute 皆啟用、剛建立 → TTL 取較小的 idle(idle < abs remaining)。
|
||
name: "both_enabled_takes_idle",
|
||
idleTTL: idle,
|
||
absoluteTTL: abs,
|
||
now: base,
|
||
createdAt: base,
|
||
wantTTL: idle,
|
||
wantExpired: false,
|
||
wantPersist: false,
|
||
},
|
||
{
|
||
// idle+absolute 皆啟用、now 對齊 absolute deadline → absRemaining 0 < idle,
|
||
// 取 absRemaining(0) → 仍須視為過期(有 absolute 上限)。
|
||
name: "both_enabled_now_equals_abs_deadline",
|
||
idleTTL: idle,
|
||
absoluteTTL: abs,
|
||
now: base.Add(abs),
|
||
createdAt: base,
|
||
wantTTL: 0,
|
||
wantExpired: true,
|
||
wantPersist: false,
|
||
},
|
||
{
|
||
// 只有 idle、無 absolute → 永遠回 idle,不受 deadline 概念影響、不過期。
|
||
name: "only_idle",
|
||
idleTTL: idle,
|
||
absoluteTTL: 0,
|
||
now: base.Add(100 * time.Hour),
|
||
createdAt: base,
|
||
wantTTL: idle,
|
||
wantExpired: false,
|
||
wantPersist: false,
|
||
},
|
||
}
|
||
|
||
for _, tc := range cases {
|
||
t.Run(tc.name, func(t *testing.T) {
|
||
s := NewRedisUserSessionStore(nil, tc.idleTTL, tc.absoluteTTL)
|
||
|
||
gotTTL := s.effectiveTTL(tc.now, tc.createdAt)
|
||
if gotTTL != tc.wantTTL {
|
||
t.Errorf("effectiveTTL = %v, want %v", gotTTL, tc.wantTTL)
|
||
}
|
||
|
||
// 重現 save 的過期決策(不需 Redis client,純比較):
|
||
// 有 absolute 上限且 ttl <= 0 → 過期;否則放行(ttl == 0 在無 absolute 時為 PERSIST)。
|
||
gotExpired := gotTTL <= 0 && s.absoluteTTL > 0
|
||
if gotExpired != tc.wantExpired {
|
||
t.Errorf("save expiry decision = %v, want %v (ttl=%v, absoluteTTL=%v)",
|
||
gotExpired, tc.wantExpired, gotTTL, s.absoluteTTL)
|
||
}
|
||
|
||
// PERSIST = ttl 0 且非過期決策(即 absoluteTTL == 0)。
|
||
gotPersist := gotTTL == 0 && !gotExpired
|
||
if gotPersist != tc.wantPersist {
|
||
t.Errorf("PERSIST decision = %v, want %v", gotPersist, tc.wantPersist)
|
||
}
|
||
})
|
||
}
|
||
}
|