Files
Commilitia-Drop/internal/clipboard/service_test.go
T
admin f21fa5b5e8 cdrop — 跨 OS 剪贴板与文件传输服务
Commilitia Drop:自托管的跨设备剪贴板同步与点对点文件传输。

- 后端 Go(chi / SQLite WAL / SSE Hub / WebRTC signaling + 状态机 / Relay ring buffer),编译进单个 distroless 镜像(前端 go:embed)。
- 前端 React + TanStack Router + Zustand,自实现 SSE + WebRTC P2P,NAT 受阻时回退服务端中继;聚珍(Juzhen)CJK 综合排版。
- 桌面端 Wails v2(macOS / Windows),瘦客户端复用 web。
- 鉴权 OIDC PKCE(自建 Casdoor 等),refresh_token 信封加密存系统密钥库;iOS Shortcut 用 HS256 scoped token。

架构文档与变更记录见 docs 分支(PROJECT_BRIEF / FRONTEND_DESIGN / CHANGELOG)。

本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
2026-06-15 21:38:28 +08:00

379 lines
11 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 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", "<b>hi</b>", "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 应为 noopgot 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)")
}
}