2caf1ab921
- 根因:presence 的 online 仅表示有活 SSE,故后台 / 墓碑态 iOS 被判离线、不进传输目标,使前面的后台响应(推送唤醒 + 离线队列)实际无从触发。诉求模型:离线但可唤醒的设备可选作目标;发送端推送唤醒对端、等其回前台上线后再走实时传输(非服务端暂存转发)。 - 后端:transfer.Init 返回 receiverOnline(=SendTo delivered);handleTransferInit 回 receiver_online。新增 ListPendingTransfersByReceiver 查询 + Service.RedeliverPendingTo——接收端重连即补发其仍 PENDING 的 transfer:incoming(窗口内),使被唤醒的 iOS 重新 arm P2P answerer(否则发送端 offer 因 p2pHandleSignal 不识未知 peer 而被丢);sse.go handleEvents 在 hub.Connect 后调用它。 - 发送端 web/transfer.ts:startOutgoingTransfer 按 receiver_online 分流——在线即 beginP2P(原 P2P + fallback 逻辑抽出);离线则 waitForPeerThenBegin:标 phase=waiting_peer、订阅 presence、对端上线即延 1.2s(待其 arm)再 beginP2P;60s 窗口内不上线则 cancel + FAILED phase=peer_offline。cleanupOutgoing 清理 waitTimer / waitUnsub。TransferPhase 加 waiting_peer / peer_offline + 三语 i18n。 - 目标纳入 + 标记:离线但可唤醒(type 含 ios)的设备可选作传输目标、标「可唤醒」。web DeviceStrip 加 isWakeable(纳入 visible、放开 disabled、标签);iOS EngineController.sendableDevices 纳入 + isWakeable + sendLabel,三处发送选择器(传输 / 分享 / 收到的文件转发)改用 sendLabel。i18n 加 home.device.wakeable。 - 机制说明(留档):content-available 静默唤醒不会让挂起的引擎重连 SSE;真正上线=用户点推送回前台 → 引擎恢复 → SSE 重连 → 后端补发 incoming → arm → 发送端 offer。若 offer 与 arm 抢拍掉了,30s 后 relay 兜底(此时对端已在线)。可唤醒判定用类型启发式(离线 + ios),未加后端 push-sub 查询。 - 测试:service_test 适配 Init 的三返回值。
352 lines
10 KiB
Go
352 lines
10 KiB
Go
package transfer
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"path/filepath"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
"commilitia.net/cdrop/internal/db"
|
|
"commilitia.net/cdrop/internal/hub"
|
|
)
|
|
|
|
// recHub records SendTo calls without spinning up real SSE clients.
|
|
// We can't easily mock *hub.Hub directly because it's a concrete type — instead
|
|
// we feed it a fake DeviceLister and ignore the broadcast side; the fact that
|
|
// SendTo to a non-connected client returns false is acceptable here.
|
|
type recordingLister struct {
|
|
mu sync.Mutex
|
|
calls int
|
|
}
|
|
|
|
func (r *recordingLister) ListDevicesByUser(_ context.Context, _ string) ([]db.Device, error) {
|
|
r.mu.Lock()
|
|
defer r.mu.Unlock()
|
|
r.calls++
|
|
return nil, nil
|
|
}
|
|
|
|
func newTestHub() *hub.Hub {
|
|
return hub.New(&recordingLister{})
|
|
}
|
|
|
|
func openTestDB(t *testing.T) *db.Queries {
|
|
t.Helper()
|
|
dbPath := filepath.Join(t.TempDir(), "x.db")
|
|
conn, err := db.Open(dbPath)
|
|
if err != nil {
|
|
t.Fatalf("open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = conn.Close() })
|
|
if err := db.Bootstrap(context.Background(), conn); err != nil {
|
|
t.Fatalf("bootstrap: %v", err)
|
|
}
|
|
return db.New(conn)
|
|
}
|
|
|
|
func newTestService(t *testing.T) *Service {
|
|
t.Helper()
|
|
q := openTestDB(t)
|
|
h := newTestHub()
|
|
t.Cleanup(h.Close)
|
|
return NewService(q, h, nil, nil, nil)
|
|
}
|
|
|
|
// fakeRelay records Register/Release calls and can be primed to fail Register,
|
|
// standing in for *relay.Manager so the transfer wiring can be exercised without
|
|
// importing relay.
|
|
type fakeRelay struct {
|
|
registers [][4]string // {id, userID, sender, receiver}
|
|
releases []string
|
|
registerErr error
|
|
}
|
|
|
|
func (f *fakeRelay) Register(id, userID, sender, receiver string) error {
|
|
if f.registerErr != nil {
|
|
return f.registerErr
|
|
}
|
|
f.registers = append(f.registers, [4]string{id, userID, sender, receiver})
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeRelay) Release(id string) { f.releases = append(f.releases, id) }
|
|
|
|
func newTestServiceWithRelay(t *testing.T, r RelayController) *Service {
|
|
t.Helper()
|
|
q := openTestDB(t)
|
|
h := newTestHub()
|
|
t.Cleanup(h.Close)
|
|
return NewService(q, h, r, nil, nil)
|
|
}
|
|
|
|
func TestInit_AllowsZeroByteFile(t *testing.T) {
|
|
svc := newTestService(t)
|
|
if _, _, err := svc.Init(context.Background(), InitParams{
|
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
|
FileName: "empty.txt", FileSize: 0,
|
|
}); err != nil {
|
|
t.Errorf("0-byte file must be accepted (brief §2 不限大小); got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestInit_PersistsPending(t *testing.T) {
|
|
svc := newTestService(t)
|
|
id, _, err := svc.Init(context.Background(), InitParams{
|
|
UserID: "alice",
|
|
SenderName: "tab-1",
|
|
ReceiverName: "tab-2",
|
|
FileName: "doc.pdf",
|
|
FileSize: 1024,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("init: %v", err)
|
|
}
|
|
if id == "" {
|
|
t.Fatal("expected non-empty id")
|
|
}
|
|
|
|
sess, err := svc.queries.GetTransferSession(context.Background(), id)
|
|
if err != nil {
|
|
t.Fatalf("get: %v", err)
|
|
}
|
|
if sess.State != StatePending {
|
|
t.Errorf("state: got %q, want %q", sess.State, StatePending)
|
|
}
|
|
if sess.UserID != "alice" || sess.SenderName != "tab-1" || sess.ReceiverName != "tab-2" {
|
|
t.Errorf("unexpected fields: %+v", sess)
|
|
}
|
|
}
|
|
|
|
func TestInit_RejectsBadInput(t *testing.T) {
|
|
svc := newTestService(t)
|
|
bad := []InitParams{
|
|
{UserID: "u", SenderName: "", ReceiverName: "r", FileName: "f", FileSize: 1},
|
|
{UserID: "u", SenderName: "s", ReceiverName: "", FileName: "f", FileSize: 1},
|
|
{UserID: "u", SenderName: "s", ReceiverName: "s", FileName: "f", FileSize: 1}, // self
|
|
{UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "", FileSize: 1},
|
|
{UserID: "u", SenderName: "s", ReceiverName: "r", FileName: "f", FileSize: -1},
|
|
}
|
|
for i, p := range bad {
|
|
if _, _, err := svc.Init(context.Background(), p); err == nil {
|
|
t.Errorf("case %d should error: %+v", i, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTransition_HappyPath_PendingToDone(t *testing.T) {
|
|
svc := newTestService(t)
|
|
ctx := context.Background()
|
|
id, _, err := svc.Init(ctx, InitParams{
|
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
|
FileName: "f", FileSize: 1024,
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("init: %v", err)
|
|
}
|
|
|
|
steps := []struct {
|
|
state, mode string
|
|
}{
|
|
{StateAccepted, ""},
|
|
{StateP2PActive, ModeP2P},
|
|
{StateDone, ""},
|
|
}
|
|
for _, step := range steps {
|
|
if err := svc.Transition(ctx, "u", id, step.state, step.mode, "", 1024); err != nil {
|
|
t.Fatalf("transition to %s: %v", step.state, err)
|
|
}
|
|
}
|
|
sess, _ := svc.queries.GetTransferSession(ctx, id)
|
|
if sess.State != StateDone {
|
|
t.Errorf("final state: got %q, want %q", sess.State, StateDone)
|
|
}
|
|
if sess.Mode == nil || *sess.Mode != ModeP2P {
|
|
t.Errorf("mode: got %v, want %q", sess.Mode, ModeP2P)
|
|
}
|
|
if sess.FinishedAt == nil {
|
|
t.Error("FinishedAt should be set on terminal state")
|
|
}
|
|
}
|
|
|
|
func TestTransition_FallbackToRelayThenDone(t *testing.T) {
|
|
svc := newTestService(t)
|
|
ctx := context.Background()
|
|
id, _, _ := svc.Init(ctx, InitParams{
|
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
|
FileName: "f", FileSize: 10,
|
|
})
|
|
for _, st := range []string{StateAccepted, StateP2PActive, StateRelayActive, StateDone} {
|
|
mode := ""
|
|
switch st {
|
|
case StateP2PActive:
|
|
mode = ModeP2P
|
|
case StateRelayActive:
|
|
mode = ModeRelay
|
|
}
|
|
if err := svc.Transition(ctx, "u", id, st, mode, "", 0); err != nil {
|
|
t.Fatalf("transition to %s: %v", st, err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Entering RELAY_ACTIVE must reserve a relay slot, recording the transfer's two
|
|
// participants so the relay endpoints can later reject anyone else (R1/G2).
|
|
func TestTransition_RegistersRelayOnFallback(t *testing.T) {
|
|
fr := &fakeRelay{}
|
|
svc := newTestServiceWithRelay(t, fr)
|
|
ctx := context.Background()
|
|
id, _, _ := svc.Init(ctx, InitParams{
|
|
UserID: "u", SenderName: "laptop", ReceiverName: "phone",
|
|
FileName: "f", FileSize: 10,
|
|
})
|
|
if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil {
|
|
t.Fatalf("accept: %v", err)
|
|
}
|
|
if err := svc.Transition(ctx, "u", id, StateRelayActive, ModeRelay, "", 0); err != nil {
|
|
t.Fatalf("fallback: %v", err)
|
|
}
|
|
if len(fr.registers) != 1 {
|
|
t.Fatalf("expected 1 Register call, got %d", len(fr.registers))
|
|
}
|
|
if got := fr.registers[0]; got != [4]string{id, "u", "laptop", "phone"} {
|
|
t.Errorf("Register args: got %v", got)
|
|
}
|
|
}
|
|
|
|
// When the relay is full, the fallback must NOT advance the transfer into relay
|
|
// mode: Transition surfaces ErrRelayUnavailable and the persisted state is
|
|
// unchanged so the client can retry.
|
|
func TestTransition_RelayFullSurfacesUnavailable(t *testing.T) {
|
|
fr := &fakeRelay{registerErr: errors.New("too many")}
|
|
svc := newTestServiceWithRelay(t, fr)
|
|
ctx := context.Background()
|
|
id, _, _ := svc.Init(ctx, InitParams{
|
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
|
FileName: "f", FileSize: 10,
|
|
})
|
|
if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil {
|
|
t.Fatalf("accept: %v", err)
|
|
}
|
|
err := svc.Transition(ctx, "u", id, StateRelayActive, ModeRelay, "", 0)
|
|
if !errors.Is(err, ErrRelayUnavailable) {
|
|
t.Fatalf("expected ErrRelayUnavailable, got %v", err)
|
|
}
|
|
sess, _ := svc.queries.GetTransferSession(ctx, id)
|
|
if sess.State != StateAccepted {
|
|
t.Errorf("state must be unchanged on relay-full; got %q, want ACCEPTED", sess.State)
|
|
}
|
|
}
|
|
|
|
func TestTransition_RejectsInvalidJump(t *testing.T) {
|
|
svc := newTestService(t)
|
|
ctx := context.Background()
|
|
id, _, _ := svc.Init(ctx, InitParams{
|
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
|
FileName: "f", FileSize: 10,
|
|
})
|
|
// PENDING → DONE 仍非法(必须先 ACCEPTED 或 P2P_ACTIVE / RELAY_ACTIVE 至少一步)
|
|
if err := svc.Transition(ctx, "u", id, StateDone, "", "", 0); err == nil {
|
|
t.Error("PENDING→DONE should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestTransition_PendingToActiveAllowed(t *testing.T) {
|
|
// Race tolerance: sender may call /p2p before receiver's /accept commits.
|
|
svc := newTestService(t)
|
|
ctx := context.Background()
|
|
id, _, _ := svc.Init(ctx, InitParams{
|
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
|
FileName: "f", FileSize: 10,
|
|
})
|
|
if err := svc.Transition(ctx, "u", id, StateP2PActive, ModeP2P, "", 0); err != nil {
|
|
t.Errorf("PENDING→P2P_ACTIVE should be allowed: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTransition_AcceptedToDoneAllowed(t *testing.T) {
|
|
// Race tolerance: receiver may finish receiving (call /done) before /p2p ever fired.
|
|
svc := newTestService(t)
|
|
ctx := context.Background()
|
|
id, _, _ := svc.Init(ctx, InitParams{
|
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
|
FileName: "f", FileSize: 10,
|
|
})
|
|
if err := svc.Transition(ctx, "u", id, StateAccepted, "", "", 0); err != nil {
|
|
t.Fatalf("PENDING→ACCEPTED: %v", err)
|
|
}
|
|
if err := svc.Transition(ctx, "u", id, StateDone, "", "", 10); err != nil {
|
|
t.Errorf("ACCEPTED→DONE should be allowed (race-tolerant): %v", err)
|
|
}
|
|
}
|
|
|
|
func TestTransition_RejectsForeignUser(t *testing.T) {
|
|
svc := newTestService(t)
|
|
ctx := context.Background()
|
|
id, _, _ := svc.Init(ctx, InitParams{
|
|
UserID: "alice", SenderName: "s", ReceiverName: "r",
|
|
FileName: "f", FileSize: 10,
|
|
})
|
|
if err := svc.Transition(ctx, "mallory", id, StateAccepted, "", "", 0); err == nil {
|
|
t.Error("foreign user must be forbidden")
|
|
}
|
|
}
|
|
|
|
func TestTransition_NotFound(t *testing.T) {
|
|
svc := newTestService(t)
|
|
if err := svc.Transition(context.Background(), "u", "nope", StateAccepted, "", "", 0); err == nil {
|
|
t.Error("unknown id should error")
|
|
}
|
|
}
|
|
|
|
func TestExpirePending_CancelsOldSessions(t *testing.T) {
|
|
svc := newTestService(t)
|
|
ctx := context.Background()
|
|
|
|
old := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
svc.now = func() time.Time { return old }
|
|
|
|
id, _, _ := svc.Init(ctx, InitParams{
|
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
|
FileName: "f", FileSize: 10,
|
|
})
|
|
|
|
// jump 200s into the future; PendingTTL is 120s so this should expire.
|
|
svc.now = func() time.Time { return old.Add(200 * time.Second) }
|
|
n, err := svc.ExpirePending(ctx, PendingTTL)
|
|
if err != nil {
|
|
t.Fatalf("expire: %v", err)
|
|
}
|
|
if n != 1 {
|
|
t.Errorf("expired count: got %d, want 1", n)
|
|
}
|
|
sess, _ := svc.queries.GetTransferSession(ctx, id)
|
|
if sess.State != StateCancelled {
|
|
t.Errorf("state: got %q, want %q", sess.State, StateCancelled)
|
|
}
|
|
if sess.FailReason == nil || *sess.FailReason != "pending_timeout" {
|
|
t.Errorf("fail_reason: got %v, want pending_timeout", sess.FailReason)
|
|
}
|
|
}
|
|
|
|
func TestExpirePending_LeavesFreshAlone(t *testing.T) {
|
|
svc := newTestService(t)
|
|
ctx := context.Background()
|
|
id, _, _ := svc.Init(ctx, InitParams{
|
|
UserID: "u", SenderName: "s", ReceiverName: "r",
|
|
FileName: "f", FileSize: 10,
|
|
})
|
|
n, err := svc.ExpirePending(ctx, PendingTTL)
|
|
if err != nil {
|
|
t.Fatalf("expire: %v", err)
|
|
}
|
|
if n != 0 {
|
|
t.Errorf("expired count: got %d, want 0", n)
|
|
}
|
|
sess, _ := svc.queries.GetTransferSession(ctx, id)
|
|
if sess.State != StatePending {
|
|
t.Errorf("state: got %q, want PENDING", sess.State)
|
|
}
|
|
}
|