Files
telegram-exporter/internal/tdlkv/bolt_test.go
T
tiennm99 b73b74efd5 feat: add Go binary embedding tdl and rclone as libraries
First slice of replacing the three-script pipeline with one process. The
scripts coordinate tdl and rclone as separate programs, so everything
expensive in them exists to work around the fact that neither can see the
other's state. A single process does not need that machinery.

This slice covers only the foundations: open the session tdl login already
wrote, resolve an rclone destination, and report on both via a doctor
command. Downloading, uploading and verification follow.

The session store is shared with the tdl CLI rather than copied, so the two
cannot run against one namespace at the same time; -n selects another.

AppID and AppHash are read from the store rather than hardcoded, because a
session is bound to the application that created it and tdl records which
one it used.

No middlewares are passed to tclient.New, which already prepends its own
defaults; the DC pool gets them instead, since gotd applies a client's
middlewares only to direct invocations and not to pooled connections.

The rclone config is loaded up front because the lazy path calls os.Exit on
a config it cannot read, which would bypass every defer and exit with the
code this tool reserves for an incomplete run.

Exit codes follow the shell pipeline: 0 ok, 1 incomplete, 2 usage, 3 remote
failure, 130 SIGINT, 143 SIGTERM. Cancellation is checked explicitly after
the Telegram client returns, because gotd reports an interrupted run as
success and a driver would read that as a finished archive.
2026-09-06 17:00:45 +07:00

180 lines
4.9 KiB
Go

package tdlkv
import (
"context"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"go.etcd.io/bbolt"
"github.com/iyear/tdl/core/storage"
)
// newFixture writes a bolt file laid out the way tdl's driver lays one out:
// file named after the namespace, single bucket of the same name.
func newFixture(t *testing.T, dir, ns string, pairs map[string]string) {
t.Helper()
db, err := bbolt.Open(filepath.Join(dir, ns), 0o600, nil)
if err != nil {
t.Fatalf("create fixture: %v", err)
}
defer func() { _ = db.Close() }()
if err := db.Update(func(tx *bbolt.Tx) error {
b, err := tx.CreateBucketIfNotExists([]byte(ns))
if err != nil {
return err
}
for k, v := range pairs {
if err := b.Put([]byte(k), []byte(v)); err != nil {
return err
}
}
return nil
}); err != nil {
t.Fatalf("seed fixture: %v", err)
}
}
func TestOpenReadsTdlLayout(t *testing.T) {
dir := t.TempDir()
newFixture(t, dir, "default", map[string]string{"app": "desktop"})
s, err := Open(dir, "default")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer func() { _ = s.Close() }()
got, err := s.Get(context.Background(), "app")
if err != nil {
t.Fatalf("Get: %v", err)
}
if string(got) != "desktop" {
t.Errorf("Get(app) = %q, want %q", got, "desktop")
}
}
// A missing key must be distinguishable from an error, because core/storage
// consumers branch on ErrNotFound to fall back to defaults.
func TestGetMissingKeyReturnsErrNotFound(t *testing.T) {
dir := t.TempDir()
newFixture(t, dir, "default", nil)
s, err := Open(dir, "default")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer func() { _ = s.Close() }()
if _, err := s.Get(context.Background(), "absent"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Get(absent) error = %v, want storage.ErrNotFound", err)
}
}
func TestSetAndDeleteRoundTrip(t *testing.T) {
dir := t.TempDir()
newFixture(t, dir, "default", nil)
s, err := Open(dir, "default")
if err != nil {
t.Fatalf("Open: %v", err)
}
defer func() { _ = s.Close() }()
ctx := context.Background()
if err := s.Set(ctx, "session", []byte("blob")); err != nil {
t.Fatalf("Set: %v", err)
}
got, err := s.Get(ctx, "session")
if err != nil || string(got) != "blob" {
t.Fatalf("Get after Set = %q, %v", got, err)
}
if err := s.Delete(ctx, "session"); err != nil {
t.Fatalf("Delete: %v", err)
}
if _, err := s.Get(ctx, "session"); !errors.Is(err, storage.ErrNotFound) {
t.Errorf("Get after Delete error = %v, want storage.ErrNotFound", err)
}
}
// The three failure modes must stay distinguishable in the message, because
// each one tells the operator to do something different.
func TestOpenFailureMessages(t *testing.T) {
t.Run("missing namespace file", func(t *testing.T) {
_, err := Open(t.TempDir(), "default")
if err == nil {
t.Fatal("expected an error for a missing session file")
}
if !strings.Contains(err.Error(), "tdl login") {
t.Errorf("error should point at `tdl login`, got: %v", err)
}
})
t.Run("non-default namespace names the flag", func(t *testing.T) {
_, err := Open(t.TempDir(), "second")
if err == nil {
t.Fatal("expected an error for a missing session file")
}
if !strings.Contains(err.Error(), "-n second") {
t.Errorf("error should name the namespace flag, got: %v", err)
}
})
t.Run("file present but bucket missing", func(t *testing.T) {
dir := t.TempDir()
db, err := bbolt.Open(filepath.Join(dir, "default"), 0o600, nil)
if err != nil {
t.Fatalf("create empty db: %v", err)
}
_ = db.Close()
if _, err := Open(dir, "default"); err == nil {
t.Fatal("expected an error for a bucketless store")
} else if !strings.Contains(err.Error(), "incomplete") {
t.Errorf("error should call the session incomplete, got: %v", err)
}
})
t.Run("empty namespace rejected", func(t *testing.T) {
if _, err := Open(t.TempDir(), ""); err == nil {
t.Fatal("expected an error for an empty namespace")
}
})
}
// Open must not create a session that does not exist: silently handing back an
// empty store would surface much later as a confusing auth failure.
func TestOpenDoesNotCreateMissingStore(t *testing.T) {
dir := t.TempDir()
if _, err := Open(dir, "default"); err == nil {
t.Fatal("expected an error")
}
if _, err := os.Stat(filepath.Join(dir, "default")); !os.IsNotExist(err) {
t.Errorf("Open created %s; it must never create a session file", filepath.Join(dir, "default"))
}
}
// A second opener must be told the store is locked rather than hanging.
func TestOpenReportsLockContention(t *testing.T) {
dir := t.TempDir()
newFixture(t, dir, "default", nil)
first, err := Open(dir, "default")
if err != nil {
t.Fatalf("first Open: %v", err)
}
defer func() { _ = first.Close() }()
_, err = Open(dir, "default")
if err == nil {
t.Fatal("expected the second Open to fail while the first holds the lock")
}
if !strings.Contains(err.Error(), "locked by another process") {
t.Errorf("error should name lock contention, got: %v", err)
}
}