- 引入 LogicalConn/TransportConn 分层,ClientConn 保留兼容适配层 - 新增 Stream、Bulk、RecordStream 三条数据面能力及对应控制路径 - 完成 transfer/file 传输内核与状态快照、诊断能力 - 补齐 reconnect、inbound dispatcher、modern psk 等基础模块 - 增加大规模回归、并发与基准测试覆盖 - 更新依赖库
521 lines
15 KiB
Go
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[:])
|
|
}
|