Files
imap-copier/internal/imapx/copy_test.go
T
vasyansk b6e68bdd90 Add activity tracking to prevent stall timeouts during message transfers
Add OnActivity callback to CopyDeps to prevent stall timeouts during large message transfers
Implement touchReader and touchWriter wrappers to call OnActivity during FETCH and APPEND operations
Add slow message logging to identify performance bottlenecks
Add test case to verify activity reporting during message transfers
Clean up orchestrator account reset code formatting
2026-07-21 04:38:32 +07:00

271 lines
7.7 KiB
Go

package imapx
import (
"context"
"fmt"
"testing"
"time"
"github.com/emersion/go-imap/v2"
)
// seedInbox logs in as login/pass and APPENDs n minimal messages with unique
// Message-IDs into INBOX via a dedicated connection.
func seedInbox(t *testing.T, ep Endpoint, login, pass string, n int) {
t.Helper()
ctx := context.Background()
c, err := Connect(ctx, ep)
if err != nil {
t.Fatalf("seedInbox connect: %v", err)
}
defer func() { _ = c.Logout().Wait() }()
if err := c.Login(login, pass).Wait(); err != nil {
t.Fatalf("seedInbox login: %v", err)
}
for i := 0; i < n; i++ {
msg := fmt.Sprintf(
"From: sender@localhost\r\nTo: %s\r\nSubject: seed %d\r\nMessage-Id: <seed-%d-%p@localhost>\r\n\r\nBody %d\r\n",
login, i, i, &i, i,
)
buf := []byte(msg)
appendCmd := c.Append("INBOX", int64(len(buf)), nil)
if _, err := appendCmd.Write(buf); err != nil {
t.Fatalf("seedInbox write %d: %v", i, err)
}
if err := appendCmd.Close(); err != nil {
t.Fatalf("seedInbox close %d: %v", i, err)
}
if _, err := appendCmd.Wait(); err != nil {
t.Fatalf("seedInbox append %d: %v", i, err)
}
}
}
// seedInboxWithDate APPENDs a single message with a given subject and a
// KNOWN IMAP internal date (via AppendOptions.Time), so the test can assert
// the date survives the copy instead of silently becoming "now" on dst.
func seedInboxWithDate(t *testing.T, ep Endpoint, login, pass, subject string, when time.Time) {
t.Helper()
ctx := context.Background()
c, err := Connect(ctx, ep)
if err != nil {
t.Fatalf("seedInboxWithDate connect: %v", err)
}
defer func() { _ = c.Logout().Wait() }()
if err := c.Login(login, pass).Wait(); err != nil {
t.Fatalf("seedInboxWithDate login: %v", err)
}
msg := fmt.Sprintf(
"From: sender@localhost\r\nTo: %s\r\nSubject: %s\r\nMessage-Id: <%s@localhost>\r\n\r\nBody\r\n",
login, subject, subject,
)
buf := []byte(msg)
appendCmd := c.Append("INBOX", int64(len(buf)), &imap.AppendOptions{Time: when})
if _, err := appendCmd.Write(buf); err != nil {
t.Fatalf("seedInboxWithDate write: %v", err)
}
if err := appendCmd.Close(); err != nil {
t.Fatalf("seedInboxWithDate close: %v", err)
}
if _, err := appendCmd.Wait(); err != nil {
t.Fatalf("seedInboxWithDate append: %v", err)
}
}
// fetchInternalDateBySubject connects, selects INBOX and returns the
// InternalDate of the first message whose Envelope.Subject matches.
func fetchInternalDateBySubject(t *testing.T, ep Endpoint, login, pass, subject string) time.Time {
t.Helper()
ctx := context.Background()
c, err := Connect(ctx, ep)
if err != nil {
t.Fatalf("fetchInternalDateBySubject connect: %v", err)
}
defer func() { _ = c.Logout().Wait() }()
if err := c.Login(login, pass).Wait(); err != nil {
t.Fatalf("fetchInternalDateBySubject login: %v", err)
}
sel, err := c.Select("INBOX", &imap.SelectOptions{ReadOnly: true}).Wait()
if err != nil {
t.Fatalf("fetchInternalDateBySubject select: %v", err)
}
if sel.NumMessages == 0 {
t.Fatalf("fetchInternalDateBySubject: INBOX empty")
}
set := imap.SeqSet{imap.SeqRange{Start: 1, Stop: sel.NumMessages}}
msgs, err := c.Fetch(set, &imap.FetchOptions{
Envelope: true, InternalDate: true,
}).Collect()
if err != nil {
t.Fatalf("fetchInternalDateBySubject fetch: %v", err)
}
for _, m := range msgs {
if m.Envelope != nil && m.Envelope.Subject == subject {
return m.InternalDate
}
}
t.Fatalf("fetchInternalDateBySubject: subject %q not found among %d messages", subject, len(msgs))
return time.Time{}
}
// TestCopyFolderPreservesInternalDate proves CopyFolder threads the source
// message's IMAP internal date through to APPEND on dst, instead of letting
// dst stamp it with "now" at APPEND time.
func TestCopyFolderPreservesInternalDate(t *testing.T) {
ep := testEP(t)
ctx := context.Background()
const subject = "datecheck"
knownTime := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
seedInboxWithDate(t, ep, "datesrc@localhost", "p", subject, knownTime)
src, err := Connect(ctx, ep)
if err != nil {
t.Fatal(err)
}
defer func() { _ = src.Logout().Wait() }()
if err := src.Login("datesrc@localhost", "p").Wait(); err != nil {
t.Fatal(err)
}
dst, err := Connect(ctx, ep)
if err != nil {
t.Fatal(err)
}
defer func() { _ = dst.Logout().Wait() }()
if err := dst.Login("datedst@localhost", "p").Wait(); err != nil {
t.Fatal(err)
}
deps := CopyDeps{
IsMigrated: func(string) (bool, error) { return false, nil },
MarkMigrated: func(_, _ string) error { return nil },
OnProgress: func(_, _ int) {},
}
r, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
if err != nil {
t.Fatalf("CopyFolder: %v", err)
}
if r.Copied != 1 {
t.Fatalf("copied=%d want 1", r.Copied)
}
got := fetchInternalDateBySubject(t, ep, "datedst@localhost", "p", subject).UTC().Truncate(time.Second)
want := knownTime.Truncate(time.Second)
if !got.Equal(want) {
t.Fatalf("internal date not preserved: got %v want %v", got, want)
}
}
// TestCopyFolderReportsActivityDuringBody proves CopyFolder invokes OnActivity
// while a message body is being transferred (FETCH/APPEND), not only between
// messages. This is what keeps the orchestrator's stall watchdog from killing a
// single large-but-live message whose transfer legitimately exceeds the stall
// timeout: without an in-body activity signal, one slow message looks identical
// to a wedged connection.
func TestCopyFolderReportsActivityDuringBody(t *testing.T) {
ep := testEP(t)
ctx := context.Background()
seedInbox(t, ep, "actsrc@localhost", "p", 1)
src, err := Connect(ctx, ep)
if err != nil {
t.Fatal(err)
}
defer func() { _ = src.Logout().Wait() }()
if err := src.Login("actsrc@localhost", "p").Wait(); err != nil {
t.Fatal(err)
}
dst, err := Connect(ctx, ep)
if err != nil {
t.Fatal(err)
}
defer func() { _ = dst.Logout().Wait() }()
if err := dst.Login("actdst@localhost", "p").Wait(); err != nil {
t.Fatal(err)
}
var activity int
deps := CopyDeps{
IsMigrated: func(string) (bool, error) { return false, nil },
MarkMigrated: func(_, _ string) error { return nil },
OnProgress: func(_, _ int) {},
OnActivity: func() { activity++ },
}
r, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
if err != nil {
t.Fatalf("CopyFolder: %v", err)
}
if r.Copied != 1 {
t.Fatalf("copied=%d want 1", r.Copied)
}
if activity == 0 {
t.Fatal("OnActivity never called during body transfer")
}
}
// Требует два ящика на greenmail. Первый запуск копирует N, второй — 0 (все skipped).
func TestCopyFolderIdempotent(t *testing.T) {
ep := testEP(t) // plain greenmail
ctx := context.Background()
// подготовка: APPEND 2 письма в INBOX источника через отдельное соединение
seedInbox(t, ep, "src@localhost", "p", 2)
src, err := Connect(ctx, ep)
if err != nil {
t.Fatal(err)
}
defer func() { _ = src.Logout().Wait() }()
if err := src.Login("src@localhost", "p").Wait(); err != nil {
t.Fatal(err)
}
dst, err := Connect(ctx, ep)
if err != nil {
t.Fatal(err)
}
defer func() { _ = dst.Logout().Wait() }()
if err := dst.Login("dst@localhost", "p").Wait(); err != nil {
t.Fatal(err)
}
seen := map[string]bool{}
deps := CopyDeps{
IsMigrated: func(k string) (bool, error) { return seen[k], nil },
MarkMigrated: func(_, k string) error { seen[k] = true; return nil },
OnProgress: func(_, _ int) {},
}
r1, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
if err != nil {
t.Fatalf("run1: %v", err)
}
if r1.Copied != 2 {
t.Fatalf("run1 copied=%d want 2", r1.Copied)
}
r2, err := CopyFolder(ctx, src, dst, "INBOX", "INBOX", deps)
if err != nil {
t.Fatalf("run2: %v", err)
}
if r2.Copied != 0 || r2.Skipped != 2 {
t.Fatalf("run2 copied=%d skipped=%d want 0/2", r2.Copied, r2.Skipped)
}
}