package clipboard import ( "context" "database/sql" "errors" "sync" "sync/atomic" "testing" "time" "commilitia.net/cdrop/internal/db" "commilitia.net/cdrop/internal/hub" ) // fakeQuerier 在内存里模拟 clipboard_state 表,按 user_id 单条覆写。 type fakeQuerier struct { mu sync.Mutex rows map[string]db.ClipboardState gets atomic.Int32 puts atomic.Int32 } func newFakeQuerier() *fakeQuerier { return &fakeQuerier{rows: map[string]db.ClipboardState{}} } func (f *fakeQuerier) UpsertClipboard(_ context.Context, arg db.UpsertClipboardParams) (int64, error) { f.puts.Add(1) f.mu.Lock() defer f.mu.Unlock() prev, exists := f.rows[arg.UserID] if exists && arg.OriginTs <= prev.OriginTs { return 0, sql.ErrNoRows // conditional WHERE failed → stale, no change (RETURNING no row) } version := prev.Version + 1 // absent → 0 → new version 1; else prev+1 f.rows[arg.UserID] = db.ClipboardState{ UserID: arg.UserID, ContentType: arg.ContentType, Content: arg.Content, SourceDevice: arg.SourceDevice, UpdatedAt: arg.UpdatedAt, Version: version, OriginTs: arg.OriginTs, } return version, nil } func (f *fakeQuerier) GetClipboard(_ context.Context, userID string) (db.ClipboardState, error) { f.gets.Add(1) f.mu.Lock() defer f.mu.Unlock() row, ok := f.rows[userID] if !ok { return db.ClipboardState{}, errNoRow } return row, nil } // ClearExpiredClipboards 模拟 SQL:把 content 非空且 updated_at < cutoff 的行 // 内容置 nil,返回清除行数。 func (f *fakeQuerier) ClearExpiredClipboards(_ context.Context, cutoff int64) (int64, error) { f.mu.Lock() defer f.mu.Unlock() var n int64 for k, row := range f.rows { if row.Content != nil && row.UpdatedAt < cutoff { row.Content = nil row.SourceDevice = nil f.rows[k] = row n++ } } return n, nil } var errNoRow = errors.New("no row") // fakeHub 计数 Broadcast 调用并把 events 排队让测试断言。 type fakeHub struct { mu sync.Mutex events []hub.Event } func (h *fakeHub) Broadcast(_ string, ev hub.Event) { h.mu.Lock() h.events = append(h.events, ev) h.mu.Unlock() } func (h *fakeHub) snapshot() []hub.Event { h.mu.Lock() defer h.mu.Unlock() out := make([]hub.Event, len(h.events)) copy(out, h.events) return out } func TestPutWritesDBImmediately(t *testing.T) { q := newFakeQuerier() h := &fakeHub{} s := New(q, h, 1024, 50*time.Millisecond, 0) if _, _, err := s.Put(context.Background(), "alice", "text/plain", "hello", "tab-1", 1); err != nil { t.Fatalf("Put: %v", err) } if got := q.puts.Load(); got != 1 { t.Errorf("expected 1 DB upsert, got %d", got) } got, err := s.Get(context.Background(), "alice") if err != nil { t.Fatalf("Get: %v", err) } if got.Content != "hello" { t.Errorf("content: got %q, want %q", got.Content, "hello") } } func TestPutTrailingPutOnlyBroadcastsLast(t *testing.T) { q := newFakeQuerier() h := &fakeHub{} s := New(q, h, 1024, 60*time.Millisecond, 0) ctx := context.Background() for i, text := range []string{"a", "b", "c"} { if _, _, err := s.Put(ctx, "alice", "text/plain", text, "tab-1", int64(i+1)); err != nil { t.Fatalf("Put %q: %v", text, err) } time.Sleep(10 * time.Millisecond) // 每次写都在同窗口内 } // 等待最后一次 Put 之后的窗口结束 + 余量 time.Sleep(150 * time.Millisecond) evs := h.snapshot() if len(evs) != 1 { t.Fatalf("expected 1 broadcast, got %d", len(evs)) } st, ok := evs[0].Data.(*State) if !ok { t.Fatalf("event data type: %T", evs[0].Data) } if st.Content != "c" { t.Errorf("broadcast content: got %q, want %q", st.Content, "c") } if evs[0].Type != EventType { t.Errorf("event type: got %q, want %q", evs[0].Type, EventType) } } func TestPutDifferentUsersDontInterfere(t *testing.T) { q := newFakeQuerier() h := &fakeHub{} s := New(q, h, 1024, 40*time.Millisecond, 0) ctx := context.Background() if _, _, err := s.Put(ctx, "alice", "text/plain", "alice-data", "tab-1", 1); err != nil { t.Fatalf("alice: %v", err) } if _, _, err := s.Put(ctx, "bob", "text/plain", "bob-data", "tab-1", 1); err != nil { t.Fatalf("bob: %v", err) } time.Sleep(120 * time.Millisecond) evs := h.snapshot() if len(evs) != 2 { t.Fatalf("expected 2 broadcasts (one per user), got %d", len(evs)) } } func TestPutRejectsUnsupportedType(t *testing.T) { s := New(newFakeQuerier(), &fakeHub{}, 1024, 10*time.Millisecond, 0) _, _, err := s.Put(context.Background(), "alice", "text/html", "hi", "tab-1", 1) if !errors.Is(err, ErrUnsupportedType) { t.Errorf("expected ErrUnsupportedType, got %v", err) } } func TestPutRejectsTooLarge(t *testing.T) { s := New(newFakeQuerier(), &fakeHub{}, 8, 10*time.Millisecond, 0) _, _, err := s.Put(context.Background(), "alice", "text/plain", "123456789", "tab-1", 1) if !errors.Is(err, ErrTooLarge) { t.Errorf("expected ErrTooLarge, got %v", err) } } func TestPutEmptyContentAccepted(t *testing.T) { q := newFakeQuerier() h := &fakeHub{} s := New(q, h, 1024, 30*time.Millisecond, 0) if _, _, err := s.Put(context.Background(), "alice", "text/plain", "", "tab-1", 1); err != nil { t.Errorf("empty content should be accepted (clear semantics): %v", err) } time.Sleep(80 * time.Millisecond) evs := h.snapshot() if len(evs) != 1 { t.Fatalf("expected 1 broadcast, got %d", len(evs)) } st := evs[0].Data.(*State) if st.Content != "" { t.Errorf("expected empty content, got %q", st.Content) } } func TestPutWindowExtendsOnNewWrite(t *testing.T) { q := newFakeQuerier() h := &fakeHub{} window := 80 * time.Millisecond s := New(q, h, 1024, window, 0) ctx := context.Background() t0 := time.Now() _, _, _ = s.Put(ctx, "alice", "text/plain", "first", "tab-1", 1) time.Sleep(window / 2) _, _, _ = s.Put(ctx, "alice", "text/plain", "second", "tab-1", 2) // 第一窗到期点:任何广播都不该出现(被第二个 Put 重置) time.Sleep(window/2 + 10*time.Millisecond) if got := len(h.snapshot()); got != 0 { t.Errorf("at t=%v, expected 0 broadcasts (window not yet elapsed for second), got %d", time.Since(t0), got) } // 第二窗到期之后 broadcast 应该出现,且内容是 "second" time.Sleep(window/2 + 30*time.Millisecond) evs := h.snapshot() if len(evs) != 1 { t.Fatalf("expected 1 broadcast after second window, got %d", len(evs)) } if got := evs[0].Data.(*State).Content; got != "second" { t.Errorf("broadcast content: got %q, want %q", got, "second") } } // --- TTL(短存活)--- func seedRow(q *fakeQuerier, userID, content string, ageSec int64) { c := content src := "tab-1" q.rows[userID] = db.ClipboardState{ UserID: userID, ContentType: "text/plain", Content: &c, SourceDevice: &src, UpdatedAt: time.Now().Unix() - ageSec, } } func TestGetExpiredReturnsEmpty(t *testing.T) { q := newFakeQuerier() seedRow(q, "alice", "secret", 100) // 100s 前写入 s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second) got, err := s.Get(context.Background(), "alice") if err != nil { t.Fatalf("Get: %v", err) } if got.Content != "" { t.Errorf("过期内容不应回吐,got %q", got.Content) } if got.UpdatedAt != 0 { t.Errorf("过期状态 UpdatedAt 应为 0(等同未写入),got %d", got.UpdatedAt) } } func TestGetFreshWithinTTL(t *testing.T) { q := newFakeQuerier() seedRow(q, "alice", "recent", 5) // 5s 前 s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second) got, err := s.Get(context.Background(), "alice") if err != nil { t.Fatalf("Get: %v", err) } if got.Content != "recent" { t.Errorf("TTL 内的新内容应返回,got %q", got.Content) } } func TestGetTTLDisabledNeverExpires(t *testing.T) { q := newFakeQuerier() seedRow(q, "alice", "old", 100000) s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 0) // ttl=0 → 禁用 got, _ := s.Get(context.Background(), "alice") if got.Content != "old" { t.Errorf("ttl=0 永不过期,got %q", got.Content) } } func TestSweepExpiredClearsOldOnly(t *testing.T) { q := newFakeQuerier() seedRow(q, "alice", "old-secret", 100) // 过期 seedRow(q, "bob", "recent", 5) // 未过期 s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 60*time.Second) n, err := s.SweepExpired(context.Background()) if err != nil { t.Fatalf("SweepExpired: %v", err) } if n != 1 { t.Errorf("应清除 1 行(alice),got %d", n) } if c := q.rows["alice"].Content; c != nil { t.Errorf("alice 的内容应被清空(nil),got %v", *c) } if c := q.rows["bob"].Content; c == nil || *c != "recent" { t.Errorf("bob 的新内容应存活,got %v", c) } } func TestSweepExpiredNoopWhenDisabled(t *testing.T) { q := newFakeQuerier() seedRow(q, "alice", "x", 100000) s := New(q, &fakeHub{}, 1024, 10*time.Millisecond, 0) n, err := s.SweepExpired(context.Background()) if err != nil || n != 0 { t.Errorf("ttl=0 时 SweepExpired 应为 noop,got n=%d err=%v", n, err) } if q.rows["alice"].Content == nil { t.Error("ttl=0 不应清除任何内容") } } func TestPutBumpsVersionMonotonically(t *testing.T) { svc := New(newFakeQuerier(), &fakeHub{}, 1024, time.Hour, 0) st1, ok, err := svc.Put(context.Background(), "u", ContentTypeText, "a", "devA", 1) if err != nil || !ok { t.Fatalf("put 1: ok=%v err=%v", ok, err) } if st1.Version != 1 { t.Errorf("first put version: got %d, want 1", st1.Version) } st2, ok, err := svc.Put(context.Background(), "u", ContentTypeText, "b", "devA", 2) if err != nil || !ok { t.Fatalf("put 2: ok=%v err=%v", ok, err) } if st2.Version != 2 { t.Errorf("second put version: got %d, want 2", st2.Version) } got, err := svc.Get(context.Background(), "u") if err != nil { t.Fatalf("get: %v", err) } if got.Version != 2 || got.SourceDevice != "devA" { t.Errorf("get: version=%d source=%q, want 2/devA", got.Version, got.SourceDevice) } } // LWW: a delayed/out-of-order write with an older-or-equal origin_ts must be // rejected (accepted=false) and leave the current winner intact. func TestPutRejectsStaleOrigin(t *testing.T) { svc := New(newFakeQuerier(), &fakeHub{}, 1024, time.Hour, 0) ctx := context.Background() st, ok, err := svc.Put(ctx, "u", ContentTypeText, "new", "devA", 200) if err != nil || !ok || st.Version != 1 { t.Fatalf("first put: ok=%v ver=%v err=%v", ok, st.Version, err) } // Older copy arriving late → rejected, current winner returned. cur, ok, err := svc.Put(ctx, "u", ContentTypeText, "stale", "devB", 100) if err != nil { t.Fatalf("stale put err: %v", err) } if ok { t.Error("older origin_ts must be rejected") } if cur.Content != "new" || cur.Version != 1 { t.Errorf("rejected put must return current winner; got content=%q ver=%d", cur.Content, cur.Version) } // Equal origin_ts is not strictly greater → rejected (idempotent duplicate). if _, ok, _ := svc.Put(ctx, "u", ContentTypeText, "dup", "devA", 200); ok { t.Error("equal origin_ts must be rejected (idempotent)") } }