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