Files
Commilitia-Drop/internal/transfer/service_test.go
T
admin 44096069a7 iOS 计划完成:流式发送 / 后台续传 / APNs / Share Extension / 控制中心控件 + 真机分发预备
- 发送端流式(R-iOS-4):新增 FileSource 抽象(web/src/features/transfer/source.ts),iOS 经 HTTP Range 惰性拉取(原生 WKURLSchemeHandler 认 Range 头 seek 回 206),整文件不进 WebView 内存;web/桌面经 fileSource 包装字节不变。p2p/relay/transfer 改吃 FileSource
- 后台续传:iOS 26 BGContinuedProcessingTask(BackgroundTaskManager + AppDelegate 注册 + BGTaskSchedulerPermittedIdentifiers),引擎传输进度驱动系统进度 UI
- APNs:后端 internal/apns(ES256 .p8 JWT + HTTP/2,仿 internal/push)+ push_subscriptions.platform 列 + POST /api/push/apns/register + transfer/message 未投递分支分流;原生 PushRegistry 授权 + 设备令牌经引擎桥 registerPush 上报;aps-environment + remote-notification 后台模式
- Share Extension:CDropShare appex 抓文件 → App Group 收件箱 → cdrop://share 深链唤主程序选设备发送(只交接、不跑引擎)
- 控制中心剪贴板两控件:CDropWidgets widgetkit appex(ControlWidget + AppIntent + 纯原生 ClipboardClient REST);用专用 broker 设备会话(主程序经引擎 provisionWidgetSession 代铸独立 device_id 会话存 App Group,控件自刷不与引擎抢 refresh 轮换)
- 真机分发预备:committed 签名改 ad-hoc 使模拟器 entitlement 生效;真机手动签名 scaffold(Signing.xcconfig 仅 device SDK 套 Manual + gitignore 的 Local.xcconfig 填 Team/profile)+ just ios-device / ios-devices 全 CLI 装机;包名改 net.commilitia.Commilitia-Drop(含 .share/.widgets,BG task 前缀同改);REALDEVICE.md 重写为门户 + CLI 装机手册
- I18n.swift 移 Shared/ 供三 target 共用;i18n 加 ios.bg/share/control.* 键
- ultracode 多 agent 审查修复(0 HIGH,2 MED + 7 LOW):分享 send 后留收件箱致重发→move 私有暂存;WidgetSession 锁屏文件保护→UntilFirstUserAuthentication;outgoing/安全作用域登出释放;控件 device_id 登出清理;并发控件 refresh CAS-before-clear;APNs 读 reason + prune bad token + bust 过期 JWT;APNSEnv 大小写归一;Share-ext completeRequest 挪进 open 回调
2026-06-27 00:33:35 +08:00

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)
}
}