f21fa5b5e8
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)。 本次为公开发布初始提交:完整开发历史(含部署细节)留存于私有归档,公开仓库自此干净起步。
199 lines
5.3 KiB
Go
199 lines
5.3 KiB
Go
package hub
|
|
|
|
import (
|
|
"context"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"commilitia.net/cdrop/internal/db"
|
|
)
|
|
|
|
// fakeLister returns a fixed device list and counts calls.
|
|
type fakeLister struct {
|
|
devices []db.Device
|
|
calls atomic.Int32
|
|
}
|
|
|
|
func (f *fakeLister) ListDevicesByUser(_ context.Context, _ string) ([]db.Device, error) {
|
|
f.calls.Add(1)
|
|
return f.devices, nil
|
|
}
|
|
|
|
func waitForEvent(t *testing.T, c *Client, want string, timeout time.Duration) Event {
|
|
t.Helper()
|
|
select {
|
|
case ev, ok := <-c.Events():
|
|
if !ok {
|
|
t.Fatalf("event channel closed; wanted type=%q", want)
|
|
}
|
|
if want != "" && ev.Type != want {
|
|
t.Fatalf("event type: got %q, want %q", ev.Type, want)
|
|
}
|
|
return ev
|
|
case <-time.After(timeout):
|
|
t.Fatalf("timeout waiting for event type=%q", want)
|
|
}
|
|
return Event{}
|
|
}
|
|
|
|
func TestConnectFiresPresenceEvent(t *testing.T) {
|
|
lister := &fakeLister{
|
|
devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1}},
|
|
}
|
|
h := New(lister)
|
|
defer h.Close()
|
|
|
|
c := h.Connect(context.Background(), "alice", "tab-1")
|
|
defer h.Disconnect(c)
|
|
|
|
ev := waitForEvent(t, c, "presence", time.Second)
|
|
data, _ := ev.Data.(map[string]any)
|
|
devs, _ := data["devices"].([]PresenceDevice)
|
|
if len(devs) != 1 || devs[0].Name != "tab-1" || !devs[0].Online {
|
|
t.Errorf("presence devices: %+v", devs)
|
|
}
|
|
}
|
|
|
|
func TestSecondClientSeesFirstAsOnline(t *testing.T) {
|
|
lister := &fakeLister{
|
|
devices: []db.Device{
|
|
{UserID: "alice", Name: "tab-1", Type: "browser", LastSeen: 1},
|
|
{UserID: "alice", Name: "tab-2", Type: "browser", LastSeen: 2},
|
|
},
|
|
}
|
|
h := New(lister)
|
|
defer h.Close()
|
|
|
|
c1 := h.Connect(context.Background(), "alice", "tab-1")
|
|
defer h.Disconnect(c1)
|
|
_ = waitForEvent(t, c1, "presence", time.Second)
|
|
|
|
c2 := h.Connect(context.Background(), "alice", "tab-2")
|
|
defer h.Disconnect(c2)
|
|
|
|
// c2 receives its own presence (announced on Connect).
|
|
ev := waitForEvent(t, c2, "presence", time.Second)
|
|
devs, _ := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
|
|
if len(devs) != 2 {
|
|
t.Fatalf("expected 2 devices in presence, got %+v", devs)
|
|
}
|
|
online := map[string]bool{}
|
|
for _, d := range devs {
|
|
online[d.Name] = d.Online
|
|
}
|
|
if !online["tab-1"] || !online["tab-2"] {
|
|
t.Errorf("both tabs should be online: %+v", online)
|
|
}
|
|
|
|
// c1 also gets presence diff for c2's arrival.
|
|
ev = waitForEvent(t, c1, "presence", time.Second)
|
|
devs, _ = ev.Data.(map[string]any)["devices"].([]PresenceDevice)
|
|
if len(devs) != 2 {
|
|
t.Errorf("c1 presence after tab-2 arrival: got %d devices, want 2", len(devs))
|
|
}
|
|
}
|
|
|
|
func TestSendToRoutesEvent(t *testing.T) {
|
|
lister := &fakeLister{}
|
|
h := New(lister)
|
|
defer h.Close()
|
|
|
|
c := h.Connect(context.Background(), "alice", "tab-1")
|
|
defer h.Disconnect(c)
|
|
_ = waitForEvent(t, c, "presence", time.Second)
|
|
|
|
if !h.SendTo("alice", "tab-1", Event{Type: "signal", Data: map[string]any{"x": 1}}) {
|
|
t.Fatal("SendTo to live client should report true")
|
|
}
|
|
ev := waitForEvent(t, c, "signal", time.Second)
|
|
if ev.Data.(map[string]any)["x"] != 1 {
|
|
t.Errorf("payload: %+v", ev.Data)
|
|
}
|
|
}
|
|
|
|
func TestSendToOfflineDeviceReportsFalse(t *testing.T) {
|
|
h := New(&fakeLister{})
|
|
defer h.Close()
|
|
if h.SendTo("ghost-user", "ghost-device", Event{Type: "signal"}) {
|
|
t.Error("SendTo to offline target should report false")
|
|
}
|
|
}
|
|
|
|
func TestReconnectClosesOldChannel(t *testing.T) {
|
|
h := New(&fakeLister{})
|
|
defer h.Close()
|
|
|
|
old := h.Connect(context.Background(), "alice", "tab-1")
|
|
// drain the initial presence so we can detect close
|
|
<-old.Events()
|
|
|
|
_ = h.Connect(context.Background(), "alice", "tab-1")
|
|
|
|
select {
|
|
case _, ok := <-old.Events():
|
|
if ok {
|
|
t.Error("old channel should be closed on reconnect from same (user, device)")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timeout waiting for old channel close")
|
|
}
|
|
}
|
|
|
|
func TestDisconnectRemovesFromOnlineSet(t *testing.T) {
|
|
h := New(&fakeLister{
|
|
devices: []db.Device{{UserID: "alice", Name: "tab-1", Type: "browser"}},
|
|
})
|
|
defer h.Close()
|
|
|
|
c := h.Connect(context.Background(), "alice", "tab-1")
|
|
if !h.Online("alice", "tab-1") {
|
|
t.Fatal("client should be online after Connect")
|
|
}
|
|
h.Disconnect(c)
|
|
if h.Online("alice", "tab-1") {
|
|
t.Error("client should not be online after Disconnect")
|
|
}
|
|
}
|
|
|
|
// TestGracePeriod uses a tiny grace so the test runs fast.
|
|
func TestGracePeriodDelaysOfflinePresence(t *testing.T) {
|
|
h := New(&fakeLister{
|
|
devices: []db.Device{
|
|
{UserID: "alice", Name: "tab-1", Type: "browser"},
|
|
{UserID: "alice", Name: "tab-2", Type: "browser"},
|
|
},
|
|
})
|
|
h.grace = 100 * time.Millisecond
|
|
defer h.Close()
|
|
|
|
c1 := h.Connect(context.Background(), "alice", "tab-1")
|
|
defer h.Disconnect(c1)
|
|
c2 := h.Connect(context.Background(), "alice", "tab-2")
|
|
// Drain initial presence frames
|
|
_ = waitForEvent(t, c1, "presence", time.Second)
|
|
_ = waitForEvent(t, c1, "presence", time.Second)
|
|
_ = waitForEvent(t, c2, "presence", time.Second)
|
|
|
|
h.Disconnect(c2)
|
|
// Grace 100ms: c1 should receive presence after the grace window with tab-2 offline.
|
|
select {
|
|
case ev := <-c1.Events():
|
|
if ev.Type != "presence" {
|
|
t.Fatalf("expected presence after grace, got %q", ev.Type)
|
|
}
|
|
devs := ev.Data.(map[string]any)["devices"].([]PresenceDevice)
|
|
var tab2Online bool
|
|
for _, d := range devs {
|
|
if d.Name == "tab-2" {
|
|
tab2Online = d.Online
|
|
}
|
|
}
|
|
if tab2Online {
|
|
t.Error("tab-2 should be offline in post-grace presence")
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("timeout waiting for post-grace presence")
|
|
}
|
|
}
|