package usersession import ( "context" "errors" "sync" "testing" "time" ) // withFrozenNow 暫時把 nowFunc 換成回傳固定時間,return 還原 func。 // // 這個 helper 解決「測 idle / absolute timeout 不能等真時鐘」的問題。 // 注意:nowFunc 是 package-level 變數,測試之間不可平行(用 t.Parallel 會 race)。 func withFrozenNow(t *testing.T, fixed time.Time) func() { t.Helper() orig := nowFunc nowFunc = func() time.Time { return fixed } return func() { nowFunc = orig } } // ───────────────────────────────────────────────────────── // Create / Get / Update / Delete 基本流程 // ───────────────────────────────────────────────────────── func TestInMemoryStore_CreateAndGet(t *testing.T) { store := NewInMemoryStore() 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 TestInMemoryStore_Get_NotFound(t *testing.T) { store := NewInMemoryStore() _, err := store.Get(context.Background(), "no-such-id") if !errors.Is(err, ErrNoSession) { t.Fatalf("expected ErrNoSession, got %v", err) } } func TestInMemoryStore_Get_EmptyID(t *testing.T) { store := NewInMemoryStore() _, err := store.Get(context.Background(), "") if !errors.Is(err, ErrNoSession) { t.Fatalf("expected ErrNoSession for empty id, got %v", err) } } // 驗證 Get 回傳的是副本,外部修改不影響 store 內部狀態(避免 race)。 func TestInMemoryStore_Get_ReturnsCopy(t *testing.T) { store := NewInMemoryStore() 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 TestInMemoryStore_Update_MovesLastSeenAt(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() store := NewInMemoryStore() ctx := context.Background() sess, _ := store.Create(ctx) // 把時間往前推 5 分鐘 t1 := t0.Add(5 * time.Minute) nowFunc = func() time.Time { return t1 } 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) } } func TestInMemoryStore_Update_NotFound(t *testing.T) { store := NewInMemoryStore() err := store.Update(context.Background(), &Session{ID: "ghost"}) if !errors.Is(err, ErrNoSession) { t.Fatalf("expected ErrNoSession, got %v", err) } } func TestInMemoryStore_Update_NilSession(t *testing.T) { store := NewInMemoryStore() if err := store.Update(context.Background(), nil); !errors.Is(err, ErrNoSession) { t.Fatalf("expected ErrNoSession for nil, got %v", err) } } func TestInMemoryStore_Delete(t *testing.T) { store := NewInMemoryStore() ctx := context.Background() sess, _ := store.Create(ctx) if err := store.Delete(ctx, sess.ID); err != nil { t.Fatalf("Delete: %v", err) } _, err := store.Get(ctx, sess.ID) if !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) } } // ───────────────────────────────────────────────────────── // CleanupExpired // ───────────────────────────────────────────────────────── func TestInMemoryStore_CleanupExpired_Idle(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() store := NewInMemoryStore() ctx := context.Background() sess1, _ := store.Create(ctx) // LastSeenAt = t0 sess2, _ := store.Create(ctx) // LastSeenAt = t0 // 把時間往前推 2 小時 nowFunc = func() time.Time { return t0.Add(2 * time.Hour) } // 把 sess2 的 LastSeenAt 更新到 now(透過 Update) if err := store.Update(ctx, sess2); err != nil { t.Fatalf("Update sess2: %v", err) } // idleTimeout = 1h → sess1(idle 2h)應被清,sess2(idle 0)保留。 removed, err := store.CleanupExpired(ctx, 1*time.Hour, 0) if err != nil { t.Fatalf("CleanupExpired: %v", err) } if removed != 1 { t.Fatalf("expected to remove 1 session, got %d", removed) } if _, err := store.Get(ctx, sess1.ID); !errors.Is(err, ErrNoSession) { t.Fatalf("sess1 should be gone") } if _, err := store.Get(ctx, sess2.ID); err != nil { t.Fatalf("sess2 should remain, got %v", err) } } func TestInMemoryStore_CleanupExpired_Absolute(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() store := NewInMemoryStore() ctx := context.Background() sess1, _ := store.Create(ctx) // CreatedAt = t0 // 推 8 天 nowFunc = func() time.Time { return t0.Add(8 * 24 * time.Hour) } // 持續 Update 讓 idle 永遠是新的,但 absolute 7d 已超 if err := store.Update(ctx, sess1); err != nil { t.Fatalf("Update: %v", err) } removed, err := store.CleanupExpired(ctx, 24*time.Hour, 7*24*time.Hour) if err != nil { t.Fatalf("CleanupExpired: %v", err) } if removed != 1 { t.Fatalf("expected absolute timeout to clear sess1, removed=%d", removed) } } func TestInMemoryStore_CleanupExpired_ZeroDisablesCheck(t *testing.T) { t0 := time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC) restore := withFrozenNow(t, t0) defer restore() store := NewInMemoryStore() ctx := context.Background() _, _ = store.Create(ctx) // 推 100 天 nowFunc = func() time.Time { return t0.Add(100 * 24 * time.Hour) } // idleTimeout=0 + absoluteTimeout=0 → 兩個檢查都跳過 → 不刪 removed, err := store.CleanupExpired(ctx, 0, 0) if err != nil { t.Fatalf("CleanupExpired: %v", err) } if removed != 0 { t.Fatalf("zero timeouts should disable cleanup, but removed=%d", removed) } if store.Len() != 1 { t.Fatalf("session should remain, got Len=%d", store.Len()) } } // ───────────────────────────────────────────────────────── // Race detector smoke test // ───────────────────────────────────────────────────────── // TestInMemoryStore_ConcurrentAccess 用 race detector 跑時驗證並發安全。 // // goroutines 同時 Create / Get / Update / Delete / CleanupExpired, // 必須無 data race(go test -race 會抓)。 func TestInMemoryStore_ConcurrentAccess(t *testing.T) { store := NewInMemoryStore() ctx := context.Background() const goroutines = 20 const iterations = 50 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) } }() } // 同時跑 cleanup wg.Add(1) go func() { defer wg.Done() for j := 0; j < iterations; j++ { _, _ = store.CleanupExpired(ctx, time.Nanosecond, 0) } }() wg.Wait() } // 驗證 ContextCancelled 會被尊重。 func TestInMemoryStore_RespectsContext(t *testing.T) { store := NewInMemoryStore() 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) } } // 驗證 generated session ID 是 base64url 且不重複。 func TestGenerateSessionID_Uniqueness(t *testing.T) { const n = 1000 seen := make(map[string]bool, n) for i := 0; i < n; i++ { id, err := generateSessionID() if err != nil { t.Fatalf("generateSessionID: %v", err) } if len(id) != 43 { // 32 bytes → base64url RawURLEncoding 後 43 字元 t.Fatalf("unexpected id length: %d (id=%q)", len(id), id) } if seen[id] { t.Fatalf("duplicate session id: %s", id) } seen[id] = true } }