Files
telegram-exporter/cmd/tgexport/main_test.go
T
tiennm99 aea29826c2 fix: detect truncated uploads, and stop reporting unreachable work as retryable
Verification matched on name and non-zero size, so an upload that died partway
was counted archived permanently. Comparing against the size Telegram reports
found six such objects in the live archive, one of them 221 MiB standing in for
a 2006 MiB video. They are folded into the outstanding set; rclone overwrites a
size mismatch, so another pass repairs them.

A zero Report claimed the archive was complete — nothing expected, nothing
missing — which is the value both commands hold before their Telegram callback
populates it, so any early return printed COMPLETE and exited 0 on an untouched
chat. A Report now knows whether it ran.

A name that can never be written kept the run outstanding forever while the
download set deliberately excluded it, so a driver looping on "incomplete"
walked the whole history and re-indexed the whole remote every pass for work
that could not be done. Such a run now reports STALLED and exits 4.

A destination that stopped accepting uploads was reported and then discarded,
exiting 1. The same driver would retry against a full or unreachable remote
indefinitely, downloading gigabytes each pass to upload none. It exits 3.

Free space is re-checked during the run, not only before it. An archive this
size runs for hours, and the remote can fill in the middle; discovering it
through five failed multi-gigabyte uploads wastes the download for all of them.

Also: parseSize silently wrapped to a negative or zero on a large input, which
reads downstream as "no cap"; list printed attacker-chosen filenames raw, so a
tab shifted the columns and an escape sequence reached the terminal; a missing
backend blamed credentials rather than the build; humanBytes indexed past its
unit table above 1 PiB; a second Init reported success against a config that
never loaded; and sync did not surface the basename collisions verify warned
about, though sync is the command that acts on the verdict.

The env-override test could not observe what it claimed: rclone reads RCLONE_*
at package init, so t.Setenv came too late and the assertion held with the
guard removed. It runs in a subprocess now, as does the new one covering the
index against inherited filters.
2026-09-06 20:44:08 +07:00

110 lines
3.8 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"syscall"
"testing"
"github.com/tiennm99dev/telegram-exporter/internal/pipeline"
"time"
)
// The exit-code contract is what a driver script reads to decide whether the
// archive is finished. Every mapping is asserted here because the previous
// version of this code documented the contract in a comment and then returned
// nil on interruption, which a driver would have read as "complete".
func TestExitCode(t *testing.T) {
tests := []struct {
name string
err error
sig os.Signal
want int
}{
{"success", nil, nil, exitOK},
{"help is not a failure", flag.ErrHelp, nil, exitOK},
{"wrapped help", fmt.Errorf("parse: %w", flag.ErrHelp), nil, exitOK},
{"usage mistake", fmt.Errorf("%w: bad flag", errUsage), nil, exitUsage},
{"run left work outstanding", fmt.Errorf("%w: 12 files", errIncomplete), nil, exitIncomplete},
// Distinct from incomplete on purpose: a driver retrying on 1 would
// walk the whole chat forever for work that can never be done.
{"nothing left that can be fetched", fmt.Errorf("%w: 2 files", errStalled), nil, exitStalled},
// And a destination that stopped accepting uploads is a failure, not a
// retry: the next pass would be refused identically.
{"destination refusing uploads",
fmt.Errorf("run: %w", pipeline.ErrDestinationFailing), nil, exitRemoteError},
{"remote failure", errors.New("pikpak unreachable"), nil, exitRemoteError},
{"cancelled without a signal", context.Canceled, nil, exitSIGINT},
{"wrapped cancellation", fmt.Errorf("download: %w", context.Canceled), nil, exitSIGINT},
{"SIGINT", nil, os.Interrupt, exitSIGINT},
{"SIGTERM", nil, syscall.SIGTERM, exitSIGTERM},
// A signal outranks the error it produced. Without this, an interrupted
// run whose cleanup happened to fail would exit 3 and look like a remote
// problem instead of an operator stop.
{"signal outranks error", errors.New("upload aborted"), os.Interrupt, exitSIGINT},
{"signal outranks success", nil, syscall.SIGTERM, exitSIGTERM},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := exitCode(tt.err, tt.sig); got != tt.want {
t.Errorf("exitCode(%v, %v) = %d, want %d", tt.err, tt.sig, got, tt.want)
}
})
}
}
// Codes must not collide: a driver distinguishes them by value alone.
func TestExitCodesMatchShellPipeline(t *testing.T) {
// run.sh: 0 ok, 2 usage, 3 rclone failure, 130 SIGINT, 143 SIGTERM.
// export-until-complete.sh: 1 ran but still incomplete.
want := map[string]int{
"ok": 0, "incomplete": 1, "usage": 2, "remote": 3, "sigint": 130, "sigterm": 143,
}
got := map[string]int{
"ok": exitOK, "incomplete": exitIncomplete, "usage": exitUsage,
"remote": exitRemoteError, "sigint": exitSIGINT, "sigterm": exitSIGTERM,
}
for k, w := range want {
if got[k] != w {
t.Errorf("%s exit code = %d, want %d (shell pipeline contract)", k, got[k], w)
}
}
}
func TestNotifyContextReportsSignal(t *testing.T) {
ctx, signalled := notifyContext()
if err := syscall.Kill(os.Getpid(), syscall.SIGINT); err != nil {
t.Fatalf("send SIGINT: %v", err)
}
select {
case <-ctx.Done():
case <-time.After(5 * time.Second):
t.Fatal("context was not cancelled within 5s of SIGINT")
}
if sig := signalled(); sig != syscall.SIGINT {
t.Errorf("signalled() = %v, want SIGINT", sig)
}
}
// An uninterrupted command must not be reported as signalled, or every clean
// run would exit 130.
func TestNotifyContextReportsNoSignal(t *testing.T) {
ctx, signalled := notifyContext()
if sig := signalled(); sig != nil {
t.Errorf("signalled() = %v on a clean run, want nil", sig)
}
// The accessor also releases the watcher, which cancels the context.
if ctx.Err() == nil {
t.Error("context should be cancelled once the watcher is released")
}
}