mirror of
https://github.com/tiennm99/telegram-exporter.git
synced 2026-10-14 02:16:21 +00:00
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.
243 lines
7.5 KiB
Go
243 lines
7.5 KiB
Go
package verify
|
|
|
|
import (
|
|
"slices"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/iyear/tdl/core/tmedia"
|
|
|
|
"github.com/tiennm99dev/telegram-exporter/internal/naming"
|
|
"github.com/tiennm99dev/telegram-exporter/internal/tgsource"
|
|
)
|
|
|
|
const dialog = int64(1234567890)
|
|
|
|
// fakeIndex is a remote snapshot expressed directly, so report logic is tested
|
|
// without a network or a filesystem.
|
|
type fakeIndex map[string]int64
|
|
|
|
func (f fakeIndex) Lookup(name string) (int64, bool) {
|
|
size, ok := f[name]
|
|
return size, ok
|
|
}
|
|
|
|
func (f fakeIndex) StoredUnderOtherNames(messageID int, wanted string) []string {
|
|
var others []string
|
|
for name := range f {
|
|
if name == wanted {
|
|
continue
|
|
}
|
|
if id, ok := naming.SplitStored(dialog, name); ok && id == messageID {
|
|
others = append(others, name)
|
|
}
|
|
}
|
|
slices.Sort(others)
|
|
return others
|
|
}
|
|
|
|
func item(msgID int, file string, size int64) tgsource.Item {
|
|
m := &tmedia.Media{Name: file, Size: size}
|
|
return tgsource.Item{
|
|
DialogID: dialog,
|
|
MessageID: msgID,
|
|
Name: naming.For(dialog, msgID, m),
|
|
Media: m,
|
|
}
|
|
}
|
|
|
|
func TestCheckClassifiesEveryOutcome(t *testing.T) {
|
|
items := []tgsource.Item{
|
|
item(1, "present.mp4", 5000),
|
|
item(2, "empty.mp4", 5000),
|
|
item(3, "absent.mp4", 5000),
|
|
item(4, "tiny.jpg", 500),
|
|
}
|
|
idx := fakeIndex{
|
|
"1234567890_1_present.mp4": 5000,
|
|
"1234567890_2_empty.mp4": 0,
|
|
"1234567890_4_tiny.jpg": 500,
|
|
}
|
|
|
|
r := Check(items, idx)
|
|
|
|
if r.Expected != 4 {
|
|
t.Errorf("Expected = %d, want 4", r.Expected)
|
|
}
|
|
if r.Present != 2 {
|
|
t.Errorf("Present = %d, want 2 (the non-empty ones)", r.Present)
|
|
}
|
|
if !slices.Equal(r.Absent, []int{3}) {
|
|
t.Errorf("Absent = %v, want [3]", r.Absent)
|
|
}
|
|
if !slices.Equal(r.ZeroByte, []int{2}) {
|
|
t.Errorf("ZeroByte = %v, want [2]", r.ZeroByte)
|
|
}
|
|
|
|
// Sub-1KiB is reported but still counted present: some real media is
|
|
// genuinely that small, so retrying it would loop forever.
|
|
if len(r.Tiny) != 1 || r.Tiny[0].MessageID != 4 {
|
|
t.Errorf("Tiny = %v, want just message 4", r.Tiny)
|
|
}
|
|
if slices.Contains(r.Todo(), 4) {
|
|
t.Error("a tiny file must not be queued for another fetch")
|
|
}
|
|
|
|
// Zero-byte files are retried: rclone overwrites a size-mismatched
|
|
// destination, so fetching again repairs them.
|
|
if want := []int{2, 3}; !slices.Equal(r.Todo(), want) {
|
|
t.Errorf("Todo() = %v, want %v", r.Todo(), want)
|
|
}
|
|
if r.Complete() {
|
|
t.Error("Complete() = true with work outstanding")
|
|
}
|
|
}
|
|
|
|
func TestCheckCompleteWhenEverythingIsPresent(t *testing.T) {
|
|
items := []tgsource.Item{item(1, "a.mp4", 10), item(2, "b.mp4", 20)}
|
|
idx := fakeIndex{"1234567890_1_a.mp4": 10, "1234567890_2_b.mp4": 20}
|
|
|
|
r := Check(items, idx)
|
|
if !r.Complete() {
|
|
t.Fatalf("Complete() = false, Todo() = %v", r.Todo())
|
|
}
|
|
var sb strings.Builder
|
|
r.Write(&sb)
|
|
if !strings.Contains(sb.String(), "COMPLETE") {
|
|
t.Errorf("report should say COMPLETE, got:\n%s", sb.String())
|
|
}
|
|
}
|
|
|
|
// The message that motivated the rewrite: the wanted name has a doubled '!',
|
|
// the remote holds the collapsed one that tdl's template wrote. It must be
|
|
// absent, and the stale copy must be surfaced rather than silently accepted.
|
|
func TestCheckReportsMisnamedCopiesAsAbsent(t *testing.T) {
|
|
items := []tgsource.Item{item(4242, "Pipe her!! And by her, we mean pipeperr! 1080p.mp4", 966444937)}
|
|
stale := "1234567890_4242_Pipe her! And by her, we mean pipeperr! 1080p.mp4"
|
|
idx := fakeIndex{stale: 966444937}
|
|
|
|
r := Check(items, idx)
|
|
|
|
if !slices.Equal(r.Absent, []int{4242}) {
|
|
t.Errorf("Absent = %v, want [4242] — a near-miss name is a different file", r.Absent)
|
|
}
|
|
if r.Present != 0 {
|
|
t.Errorf("Present = %d, want 0", r.Present)
|
|
}
|
|
if len(r.Misnamed) != 1 || !slices.Equal(r.Misnamed[0].Found, []string{stale}) {
|
|
t.Fatalf("Misnamed = %+v, want the stale copy reported", r.Misnamed)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
r.Write(&sb)
|
|
out := sb.String()
|
|
for _, want := range []string{"stored under a different name", stale, "delete the stale copies"} {
|
|
if !strings.Contains(out, want) {
|
|
t.Errorf("report should mention %q, got:\n%s", want, out)
|
|
}
|
|
}
|
|
}
|
|
|
|
// A name that cannot be written to a path is never counted present and never
|
|
// silently skipped — it is reported and queued, so it stays visible.
|
|
func TestCheckFlagsUnsafeNames(t *testing.T) {
|
|
items := []tgsource.Item{item(7, "../../../.config/rclone/rclone.conf", 100)}
|
|
|
|
r := Check(items, fakeIndex{})
|
|
|
|
if len(r.Unsafe) != 1 || r.Unsafe[0].MessageID != 7 {
|
|
t.Fatalf("Unsafe = %+v, want message 7 flagged", r.Unsafe)
|
|
}
|
|
if !slices.Equal(r.Absent, []int{7}) {
|
|
t.Errorf("Absent = %v, want [7]", r.Absent)
|
|
}
|
|
if r.Present != 0 {
|
|
t.Errorf("Present = %d, want 0", r.Present)
|
|
}
|
|
|
|
var sb strings.Builder
|
|
r.Write(&sb)
|
|
if !strings.Contains(sb.String(), "unarchivable") {
|
|
t.Errorf("report should flag the unsafe name, got:\n%s", sb.String())
|
|
}
|
|
// Nothing else is outstanding, so this run is as far as it can get. Saying
|
|
// "needs another pass" here is what makes a driver loop forever.
|
|
if !r.Stalled() {
|
|
t.Error("a report whose only outstanding item is unarchivable must be stalled")
|
|
}
|
|
if !strings.Contains(sb.String(), "STALLED") {
|
|
t.Errorf("report should say it is stalled, got:\n%s", sb.String())
|
|
}
|
|
}
|
|
|
|
// An unsafe name must be rejected even when something is stored under that
|
|
// message id: the index is not the authority on whether a name is writable.
|
|
func TestCheckUnsafeNameIsNeverPresent(t *testing.T) {
|
|
items := []tgsource.Item{item(7, "sub/dir.mp4", 100)}
|
|
idx := fakeIndex{"1234567890_7_sub/dir.mp4": 100}
|
|
|
|
if r := Check(items, idx); r.Present != 0 || len(r.Unsafe) != 1 {
|
|
t.Errorf("Present = %d, Unsafe = %+v; an unsafe name must never count present", r.Present, r.Unsafe)
|
|
}
|
|
}
|
|
|
|
func TestReportTodoIsSorted(t *testing.T) {
|
|
r := Report{Absent: []int{4242, 3}, ZeroByte: []int{100}}
|
|
if want := []int{3, 100, 4242}; !slices.Equal(r.Todo(), want) {
|
|
t.Errorf("Todo() = %v, want %v", r.Todo(), want)
|
|
}
|
|
}
|
|
|
|
// A zero Report is the value a command holds before its Telegram callback has
|
|
// run. It must not claim the archive is complete.
|
|
func TestZeroReportIsNotComplete(t *testing.T) {
|
|
var r Report
|
|
if r.Complete() {
|
|
t.Error("a Report that never ran reports the archive complete")
|
|
}
|
|
if r.Ran() {
|
|
t.Error("Ran() is true on a zero Report")
|
|
}
|
|
if r.Stalled() {
|
|
t.Error("a Report that never ran reports itself stalled")
|
|
}
|
|
|
|
var sb strings.Builder
|
|
r.Write(&sb)
|
|
if strings.Contains(sb.String(), "COMPLETE") {
|
|
t.Errorf("a Report that never ran printed COMPLETE:\n%s", sb.String())
|
|
}
|
|
}
|
|
|
|
// A remote object under the right name but the wrong size is the last way a
|
|
// report could say complete when it is not: an upload that died partway leaves
|
|
// exactly that, and the local copy is already gone.
|
|
func TestCheckTreatsAWrongSizeAsOutstanding(t *testing.T) {
|
|
items := []tgsource.Item{item(4242, "clip.mp4", 4096)}
|
|
idx := fakeIndex{"1234567890_4242_clip.mp4": 400}
|
|
|
|
r := Check(items, idx)
|
|
if r.Present != 0 {
|
|
t.Errorf("Present = %d, want 0 for a truncated object", r.Present)
|
|
}
|
|
if len(r.Mismatched) != 1 {
|
|
t.Fatalf("Mismatched = %v, want one entry", r.Mismatched)
|
|
}
|
|
if got := r.Mismatched[0]; got.Want != 4096 || got.Got != 400 {
|
|
t.Errorf("Mismatch = %+v, want want=4096 got=400", got)
|
|
}
|
|
if !slices.Contains(r.Todo(), 4242) {
|
|
t.Errorf("Todo = %v, want it to include 4242", r.Todo())
|
|
}
|
|
if r.Complete() {
|
|
t.Error("a truncated object was counted as a complete archive")
|
|
}
|
|
// It is fetchable, unlike an unsafe name — re-uploading overwrites it.
|
|
if !slices.Contains(r.Fetchable(), 4242) {
|
|
t.Errorf("Fetchable = %v, want it to include 4242", r.Fetchable())
|
|
}
|
|
if r.Stalled() {
|
|
t.Error("a repairable file must not be reported as stalled")
|
|
}
|
|
}
|