notify/file_receiver_test.go
starainrt 09d972c7b7
feat(notify): 重构通信内核并补齐 stream/bulk/record/transfer 能力
- 引入 LogicalConn/TransportConn 分层,ClientConn 保留兼容适配层
  - 新增 Stream、Bulk、RecordStream 三条数据面能力及对应控制路径
  - 完成 transfer/file 传输内核与状态快照、诊断能力
  - 补齐 reconnect、inbound dispatcher、modern psk 等基础模块
  - 增加大规模回归、并发与基准测试覆盖
  - 更新依赖库
2026-04-15 15:24:36 +08:00

521 lines
15 KiB
Go

package notify
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"os"
"path/filepath"
"testing"
"time"
)
func TestFileReceivePoolUsesConfiguredDirAndStableName(t *testing.T) {
pool := newFileReceivePool()
scope := "client:test"
dir := t.TempDir()
now := time.Now()
if err := pool.setDir(dir); err != nil {
t.Fatalf("setDir failed: %v", err)
}
payload := []byte("hello notify")
meta := FilePacket{
FileID: "file-1",
Name: "greeting.txt",
Size: int64(len(payload)),
Checksum: testFileChecksum(payload),
}
session, err := pool.onMeta(scope, meta, now)
if err != nil {
t.Fatalf("onMeta failed: %v", err)
}
if got, want := filepath.Dir(session.tmpPath), dir; got != want {
t.Fatalf("tmp dir mismatch: got %q want %q", got, want)
}
if got, want := session.finalPath, filepath.Join(dir, "greeting.txt"); got != want {
t.Fatalf("final path mismatch: got %q want %q", got, want)
}
session, err = pool.onChunk(scope, FilePacket{
FileID: meta.FileID,
Offset: 0,
Chunk: payload,
}, now.Add(time.Second))
if err != nil {
t.Fatalf("onChunk failed: %v", err)
}
if got, want := session.received, int64(len(payload)); got != want {
t.Fatalf("received mismatch after chunk: got %d want %d", got, want)
}
finalPath, session, err := pool.onEnd(scope, FilePacket{FileID: meta.FileID}, now.Add(2*time.Second))
if err != nil {
t.Fatalf("onEnd failed: %v", err)
}
if got, want := finalPath, filepath.Join(dir, "greeting.txt"); got != want {
t.Fatalf("completed path mismatch: got %q want %q", got, want)
}
if got, want := session.finalPath, finalPath; got != want {
t.Fatalf("session final path mismatch: got %q want %q", got, want)
}
gotData, err := os.ReadFile(finalPath)
if err != nil {
t.Fatalf("ReadFile failed: %v", err)
}
if !bytes.Equal(gotData, payload) {
t.Fatalf("completed file content mismatch: got %q want %q", gotData, payload)
}
dupMeta, err := pool.onMeta(scope, meta, now.Add(3*time.Second))
if err != nil {
t.Fatalf("duplicate onMeta failed: %v", err)
}
if got, want := dupMeta.finalPath, finalPath; got != want {
t.Fatalf("duplicate meta final path mismatch: got %q want %q", got, want)
}
dupChunk, err := pool.onChunk(scope, FilePacket{
FileID: meta.FileID,
Offset: 0,
Chunk: payload,
}, now.Add(4*time.Second))
if err != nil {
t.Fatalf("duplicate onChunk failed: %v", err)
}
if got, want := dupChunk.received, int64(len(payload)); got != want {
t.Fatalf("duplicate chunk received mismatch: got %d want %d", got, want)
}
dupPath, dupEnd, err := pool.onEnd(scope, FilePacket{FileID: meta.FileID}, now.Add(5*time.Second))
if err != nil {
t.Fatalf("duplicate onEnd failed: %v", err)
}
if got, want := dupPath, finalPath; got != want {
t.Fatalf("duplicate end path mismatch: got %q want %q", got, want)
}
if got, want := dupEnd.finalPath, finalPath; got != want {
t.Fatalf("duplicate end session final path mismatch: got %q want %q", got, want)
}
}
func TestFileReceivePoolAvoidsOverwriteWhenFinalPathBecomesBusy(t *testing.T) {
pool := newFileReceivePool()
scope := "client:test"
dir := t.TempDir()
now := time.Now()
if err := pool.setDir(dir); err != nil {
t.Fatalf("setDir failed: %v", err)
}
payload := []byte("new report payload")
meta := FilePacket{
FileID: "file-2",
Name: "report.txt",
Size: int64(len(payload)),
Checksum: testFileChecksum(payload),
}
session, err := pool.onMeta(scope, meta, now)
if err != nil {
t.Fatalf("onMeta failed: %v", err)
}
occupiedPath := session.finalPath
occupiedContent := []byte("existing report")
if err := os.WriteFile(occupiedPath, occupiedContent, 0o644); err != nil {
t.Fatalf("WriteFile occupied path failed: %v", err)
}
if _, err := pool.onChunk(scope, FilePacket{
FileID: meta.FileID,
Offset: 0,
Chunk: payload,
}, now.Add(time.Second)); err != nil {
t.Fatalf("onChunk failed: %v", err)
}
finalPath, _, err := pool.onEnd(scope, FilePacket{FileID: meta.FileID}, now.Add(2*time.Second))
if err != nil {
t.Fatalf("onEnd failed: %v", err)
}
if finalPath == occupiedPath {
t.Fatalf("expected final path to avoid occupied path %q", occupiedPath)
}
gotOccupied, err := os.ReadFile(occupiedPath)
if err != nil {
t.Fatalf("ReadFile occupied path failed: %v", err)
}
if !bytes.Equal(gotOccupied, occupiedContent) {
t.Fatalf("occupied file content changed: got %q want %q", gotOccupied, occupiedContent)
}
gotFinal, err := os.ReadFile(finalPath)
if err != nil {
t.Fatalf("ReadFile final path failed: %v", err)
}
if !bytes.Equal(gotFinal, payload) {
t.Fatalf("final file content mismatch: got %q want %q", gotFinal, payload)
}
if got, want := filepath.Dir(finalPath), dir; got != want {
t.Fatalf("final dir mismatch: got %q want %q", got, want)
}
}
func TestFileReceivePoolAbortAfterCompletionKeepsDeliveredFile(t *testing.T) {
pool := newFileReceivePool()
scope := "client:test"
dir := t.TempDir()
now := time.Now()
if err := pool.setDir(dir); err != nil {
t.Fatalf("setDir failed: %v", err)
}
payload := []byte("keep me")
meta := FilePacket{
FileID: "file-3",
Name: "keep.txt",
Size: int64(len(payload)),
Checksum: testFileChecksum(payload),
}
if _, err := pool.onMeta(scope, meta, now); err != nil {
t.Fatalf("onMeta failed: %v", err)
}
if _, err := pool.onChunk(scope, FilePacket{
FileID: meta.FileID,
Offset: 0,
Chunk: payload,
}, now.Add(time.Second)); err != nil {
t.Fatalf("onChunk failed: %v", err)
}
finalPath, _, err := pool.onEnd(scope, FilePacket{FileID: meta.FileID}, now.Add(2*time.Second))
if err != nil {
t.Fatalf("onEnd failed: %v", err)
}
if _, err := pool.onAbort(scope, FilePacket{FileID: meta.FileID}, now.Add(3*time.Second)); err != nil {
t.Fatalf("onAbort failed: %v", err)
}
gotData, err := os.ReadFile(finalPath)
if err != nil {
t.Fatalf("ReadFile final path after abort failed: %v", err)
}
if !bytes.Equal(gotData, payload) {
t.Fatalf("final file content mismatch after abort: got %q want %q", gotData, payload)
}
dupPath, _, err := pool.onEnd(scope, FilePacket{FileID: meta.FileID}, now.Add(4*time.Second))
if err != nil {
t.Fatalf("duplicate onEnd after abort failed: %v", err)
}
if got, want := dupPath, finalPath; got != want {
t.Fatalf("duplicate end path mismatch after abort: got %q want %q", got, want)
}
}
func TestFileReceivePoolAppliesMetaModeAndModTime(t *testing.T) {
pool := newFileReceivePool()
scope := "client:test"
dir := t.TempDir()
now := time.Now()
if err := pool.setDir(dir); err != nil {
t.Fatalf("setDir failed: %v", err)
}
payload := []byte("meta test")
wantMode := os.FileMode(0o640)
wantTime := time.Now().Add(-2 * time.Hour).Truncate(time.Second)
meta := FilePacket{
FileID: "file-meta",
Name: "meta.txt",
Size: int64(len(payload)),
Checksum: testFileChecksum(payload),
Mode: uint32(wantMode),
ModTime: wantTime.UnixNano(),
}
if _, err := pool.onMeta(scope, meta, now); err != nil {
t.Fatalf("onMeta failed: %v", err)
}
if _, err := pool.onChunk(scope, FilePacket{
FileID: meta.FileID,
Offset: 0,
Chunk: payload,
}, now.Add(time.Second)); err != nil {
t.Fatalf("onChunk failed: %v", err)
}
finalPath, _, err := pool.onEnd(scope, FilePacket{FileID: meta.FileID}, now.Add(2*time.Second))
if err != nil {
t.Fatalf("onEnd failed: %v", err)
}
info, err := os.Stat(finalPath)
if err != nil {
t.Fatalf("Stat failed: %v", err)
}
if got, want := info.Mode().Perm(), wantMode; got != want {
t.Fatalf("mode mismatch: got %o want %o", got, want)
}
gotMTime := info.ModTime().Truncate(time.Second)
if got, want := gotMTime, wantTime; !got.Equal(want) {
t.Fatalf("mtime mismatch: got %v want %v", got, want)
}
}
func TestFileReceivePoolScopeIsolation(t *testing.T) {
pool := newFileReceivePool()
dir := t.TempDir()
now := time.Now()
if err := pool.setDir(dir); err != nil {
t.Fatalf("setDir failed: %v", err)
}
const sharedFileID = "shared-file-id"
payloadA := []byte("from client A")
payloadB := []byte("from client B")
metaA := FilePacket{
FileID: sharedFileID,
Name: "shared.txt",
Size: int64(len(payloadA)),
Checksum: testFileChecksum(payloadA),
}
metaB := FilePacket{
FileID: sharedFileID,
Name: "shared.txt",
Size: int64(len(payloadB)),
Checksum: testFileChecksum(payloadB),
}
scopeA := "server:client-a"
scopeB := "server:client-b"
if _, err := pool.onMeta(scopeA, metaA, now); err != nil {
t.Fatalf("onMeta scopeA failed: %v", err)
}
if _, err := pool.onMeta(scopeB, metaB, now); err != nil {
t.Fatalf("onMeta scopeB failed: %v", err)
}
if _, err := pool.onChunk(scopeA, FilePacket{
FileID: sharedFileID,
Offset: 0,
Chunk: payloadA,
}, now.Add(time.Second)); err != nil {
t.Fatalf("onChunk scopeA failed: %v", err)
}
if _, err := pool.onChunk(scopeB, FilePacket{
FileID: sharedFileID,
Offset: 0,
Chunk: payloadB,
}, now.Add(time.Second)); err != nil {
t.Fatalf("onChunk scopeB failed: %v", err)
}
finalPathA, _, err := pool.onEnd(scopeA, FilePacket{FileID: sharedFileID}, now.Add(2*time.Second))
if err != nil {
t.Fatalf("onEnd scopeA failed: %v", err)
}
finalPathB, _, err := pool.onEnd(scopeB, FilePacket{FileID: sharedFileID}, now.Add(2*time.Second))
if err != nil {
t.Fatalf("onEnd scopeB failed: %v", err)
}
if finalPathA == finalPathB {
t.Fatalf("scope-isolated files should not share path: %q", finalPathA)
}
gotA, err := os.ReadFile(finalPathA)
if err != nil {
t.Fatalf("ReadFile scopeA failed: %v", err)
}
gotB, err := os.ReadFile(finalPathB)
if err != nil {
t.Fatalf("ReadFile scopeB failed: %v", err)
}
if !bytes.Equal(gotA, payloadA) {
t.Fatalf("scopeA content mismatch: got %q want %q", gotA, payloadA)
}
if !bytes.Equal(gotB, payloadB) {
t.Fatalf("scopeB content mismatch: got %q want %q", gotB, payloadB)
}
}
func TestFileReceivePoolCompletedRetentionEvictsOldest(t *testing.T) {
pool := newFileReceivePoolWithCompletedLimit(2)
dir := t.TempDir()
now := time.Now()
scope := "client:test"
if err := pool.setDir(dir); err != nil {
t.Fatalf("setDir failed: %v", err)
}
complete := func(fileID string, offset time.Duration) {
payload := []byte("payload-" + fileID)
meta := FilePacket{
FileID: fileID,
Name: fileID + ".txt",
Size: int64(len(payload)),
Checksum: testFileChecksum(payload),
}
eventTime := now.Add(offset)
if _, err := pool.onMeta(scope, meta, eventTime); err != nil {
t.Fatalf("onMeta %s failed: %v", fileID, err)
}
if _, err := pool.onChunk(scope, FilePacket{
FileID: fileID,
Offset: 0,
Chunk: payload,
}, eventTime.Add(time.Second)); err != nil {
t.Fatalf("onChunk %s failed: %v", fileID, err)
}
if _, _, err := pool.onEnd(scope, FilePacket{FileID: fileID}, eventTime.Add(2*time.Second)); err != nil {
t.Fatalf("onEnd %s failed: %v", fileID, err)
}
}
complete("done-1", 0)
complete("done-2", 10*time.Second)
activePayload := []byte("still-active")
if _, err := pool.onMeta(scope, FilePacket{
FileID: "active-1",
Name: "active-1.txt",
Size: int64(len(activePayload)),
Checksum: testFileChecksum(activePayload),
}, now.Add(20*time.Second)); err != nil {
t.Fatalf("onMeta active-1 failed: %v", err)
}
complete("done-3", 30*time.Second)
if got, want := len(pool.completed), 2; got != want {
t.Fatalf("completed size mismatch: got %d want %d", got, want)
}
if got, want := len(pool.sessions), 1; got != want {
t.Fatalf("active session size mismatch: got %d want %d", got, want)
}
if _, ok := pool.sessions[fileReceiveKey(scope, "active-1")]; !ok {
t.Fatal("active session should be retained")
}
if _, ok := pool.completed[fileReceiveKey(scope, "done-1")]; ok {
t.Fatal("oldest completed session should be evicted")
}
if _, ok := pool.completed[fileReceiveKey(scope, "done-2")]; !ok {
t.Fatal("newer completed session should be retained")
}
if _, ok := pool.completed[fileReceiveKey(scope, "done-3")]; !ok {
t.Fatal("latest completed session should be retained")
}
if _, _, err := pool.onEnd(scope, FilePacket{FileID: "done-1"}, now.Add(40*time.Second)); err == nil {
t.Fatal("evicted completed session should no longer resolve duplicate end")
}
if _, _, err := pool.onEnd(scope, FilePacket{FileID: "done-3"}, now.Add(41*time.Second)); err != nil {
t.Fatalf("latest completed session should still resolve duplicate end: %v", err)
}
}
func TestFillFileEventProgress(t *testing.T) {
event := FileEvent{
Kind: EnvelopeFileChunk,
Packet: FilePacket{Size: 200},
Received: 50,
StartedAt: time.Unix(100, 0),
UpdatedAt: time.Unix(102, 0),
}
fillFileEventProgress(&event)
if got, want := event.Total, int64(200); got != want {
t.Fatalf("total mismatch: got %d want %d", got, want)
}
if got, want := event.Percent, 25.0; got != want {
t.Fatalf("percent mismatch: got %v want %v", got, want)
}
if event.Done {
t.Fatal("chunk event should not be done")
}
if got, want := event.Duration, 2*time.Second; got != want {
t.Fatalf("duration mismatch: got %v want %v", got, want)
}
if got, want := event.RateBPS, 25.0; got != want {
t.Fatalf("rate mismatch: got %v want %v", got, want)
}
endEvent := FileEvent{
Kind: EnvelopeFileEnd,
Packet: FilePacket{Size: 200},
Received: 180,
StartedAt: time.Unix(200, 0),
UpdatedAt: time.Unix(204, 0),
}
fillFileEventProgress(&endEvent)
if !endEvent.Done {
t.Fatal("end event should be done")
}
if got, want := endEvent.Received, int64(200); got != want {
t.Fatalf("end received mismatch: got %d want %d", got, want)
}
if got, want := endEvent.Percent, 100.0; got != want {
t.Fatalf("end percent mismatch: got %v want %v", got, want)
}
if got, want := endEvent.Duration, 4*time.Second; got != want {
t.Fatalf("end duration mismatch: got %v want %v", got, want)
}
if got, want := endEvent.RateBPS, 50.0; got != want {
t.Fatalf("end rate mismatch: got %v want %v", got, want)
}
abortEvent := FileEvent{
Kind: EnvelopeFileAbort,
Packet: FilePacket{Size: 200},
Received: 60,
StartedAt: time.Unix(300, 0),
UpdatedAt: time.Unix(303, 0),
}
fillFileEventProgress(&abortEvent)
if abortEvent.Done {
t.Fatal("abort event should not be done")
}
if got, want := abortEvent.Percent, 30.0; got != want {
t.Fatalf("abort percent mismatch: got %v want %v", got, want)
}
if got, want := abortEvent.Duration, 3*time.Second; got != want {
t.Fatalf("abort duration mismatch: got %v want %v", got, want)
}
if got, want := abortEvent.RateBPS, 20.0; got != want {
t.Fatalf("abort rate mismatch: got %v want %v", got, want)
}
}
func TestFillFileEventTiming(t *testing.T) {
event := FileEvent{
Received: 120,
}
session := &fileReceiveSession{
startedAt: time.Unix(100, 0),
updatedAt: time.Unix(110, 0),
previousUpdatedAt: time.Unix(108, 0),
previousReceived: 80,
}
fillFileEventTiming(&event, session)
if got, want := event.StartedAt, session.startedAt; !got.Equal(want) {
t.Fatalf("startedAt mismatch: got %v want %v", got, want)
}
if got, want := event.UpdatedAt, session.updatedAt; !got.Equal(want) {
t.Fatalf("updatedAt mismatch: got %v want %v", got, want)
}
if got, want := event.StepDuration, 2*time.Second; got != want {
t.Fatalf("step duration mismatch: got %v want %v", got, want)
}
if got, want := event.InstantRateBPS, 20.0; got != want {
t.Fatalf("instant rate mismatch: got %v want %v", got, want)
}
}
func testFileChecksum(data []byte) string {
sum := sha256.Sum256(data)
return hex.EncodeToString(sum[:])
}