jim800121chen 4d0b870480 feat(visionA-backend): DB 接入 — 6 store 接 PostgreSQL/Redis 持久化(塊 0-5)
把 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>
2026-06-20 18:28:04 +08:00

638 lines
22 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 usersession
import (
"context"
"errors"
"sync"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
)
// setupRedisStore 起一個 in-process miniredis純 Go不需 docker回傳
// - storeRedisUserSessionStoreidle / absolute TTL 由 caller 指定)
// - mrminiredis 實例(可呼叫 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
// 做增量 FastForwardFastForward 是「把所有 TTL 減去 duration」必須用增量、不能用絕對 delta
type clock struct {
base time.Time
elapsed time.Duration // 已推進的累計量
}
// advanceTo 把時間推進到 base+deltadelta 必須 >= 目前 elapsed單調遞增
//
// 同步推進兩個時鐘:
// - nowFunc給 store 算 absolute deadlineapp 端邏輯)。
// - miniredis用增量 FastForward 推相對 TTLkey 自動過期)+ 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 testCreate / 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 snapshotround-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 應為 idleidle < 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 過期:即使持續 Updateidle 永遠新),超過 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 分鐘 Updateidle 永不過期),但 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 應回 ErrNoSessionabsolute 砍掉)。
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 時 Updatekey 仍在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 無 TTLPERSIST 語意)。
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)
}
}
// 只設 absoluteidle=0key 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 testrace detector 抓 data race
// 注意:本測試不凍結 nowFunc避免與其他凍結時間的測試 racenowFunc 是 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 nsnow 恰好對齊 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 // 是否為合法 PERSISTttl==0 但 absoluteTTL==0
}{
{
// idle + absolute 皆停用 → 合法 PERSISTttl 0、不過期
name: "both_disabled_persist",
idleTTL: 0,
absoluteTTL: 0,
now: base,
createdAt: base,
wantTTL: 0,
wantExpired: false,
wantPersist: true,
},
{
// 有 absolutenow 恰好對齊 deadlineabsRemaining == 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 取較小的 idleidle < 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)
}
})
}
}