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[:]) }