feat: add thumbnail preview, audio-only detection, /download command, tests, and bugfixes
- Add thumbnail preview with metadata (title, uploader, duration) before download - Auto-detect audio-only sites (Spotify, SoundCloud, etc.) and skip type selection - Add /download command for feature parity with UI button - Fix yt-dlp defer bug: StatusFailed was overwritten to StatusCompleted - Fix handleURLInput using editText with msgID=0 (now uses sendWithKB) - Fix filename detection: use job ID prefix for reliable file finding - Fix double answerCb between handleBackStep and handleDownloadPrompt - Fix runDownload unused msgID parameter - Fix context for download goroutine (use context.Background) - Remove unused startWebhook h parameter - Remove dead buildToggleKeyboard function - Add formatBytes, formatDuration, formatLabelForDisplay tests - Add IsAudioOnlyContent, MediaType.String tests - Add repo integration tests (users, preferences, quotas, history, delete) - Add cmd/bot tests (parseAllowedUsers, env helpers) - Add edge case tests for progress parser - Add zero/negative safety guard in formatDuration - Add .gitea, TODO.md, opencode.json to gitignore
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -6,4 +6,6 @@
|
|||||||
.env
|
.env
|
||||||
.DS_Store
|
.DS_Store
|
||||||
tmp/
|
tmp/
|
||||||
|
TODO.md
|
||||||
|
opencode.json
|
||||||
|
|
||||||
|
|||||||
15
README.md
15
README.md
@@ -1,14 +1,16 @@
|
|||||||
# uptodownbot
|
# uptodownbot
|
||||||
|
|
||||||
Telegram bot for downloading media from supported sites using yt-dlp.
|
Telegram bot for downloading media from any site yt-dlp supports.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- Download audio, video, or combined audio+video from any site yt-dlp supports
|
- Download audio, video, or combined audio+video from YouTube, Spotify, SoundCloud, Vimeo, and hundreds more
|
||||||
|
- Thumbnail preview with metadata (title, uploader, duration) before download
|
||||||
|
- Audio-only sites (Spotify, SoundCloud, YouTube Music, etc.) auto-detect and skip to audio quality selection
|
||||||
- Multi-step format selection: media type, quality, language, container
|
- Multi-step format selection: media type, quality, language, container
|
||||||
- Playlist support with paginated entry selection (10 per page)
|
- Playlist support with paginated entry selection (10 per page, select all, confirm with count)
|
||||||
- Download progress updates (every 5%)
|
- Download progress updates (every 5% with speed and ETA)
|
||||||
- Cancel downloads at any time
|
- Cancel downloads at any time (terminates yt-dlp process)
|
||||||
- Per-user quota system (daily, weekly, monthly)
|
- Per-user quota system (daily, weekly, monthly)
|
||||||
- User preferences (default quality, container, language)
|
- User preferences (default quality, container, language)
|
||||||
- Download history
|
- Download history
|
||||||
@@ -21,11 +23,12 @@ Telegram bot for downloading media from supported sites using yt-dlp.
|
|||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
- `/start` - Show main menu
|
- `/start` - Show main menu
|
||||||
|
- `/download` - Prompt for a download URL
|
||||||
- `/settings` - Open settings
|
- `/settings` - Open settings
|
||||||
- `/help` - Show help text
|
- `/help` - Show help text
|
||||||
- `/history` - View download history
|
- `/history` - View download history
|
||||||
|
|
||||||
Just send a URL to start downloading.
|
You can also send a URL directly to start downloading immediately.
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ func main() {
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if url := os.Getenv("BOT_WEBHOOK_URL"); url != "" {
|
if url := os.Getenv("BOT_WEBHOOK_URL"); url != "" {
|
||||||
startWebhook(ctx, b, h, url)
|
startWebhook(ctx, b, url)
|
||||||
} else {
|
} else {
|
||||||
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
|
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
|
||||||
slog.Warn("failed to delete webhook", "error", err)
|
slog.Warn("failed to delete webhook", "error", err)
|
||||||
|
|||||||
55
cmd/bot/main_test.go
Normal file
55
cmd/bot/main_test.go
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseAllowedUsers(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
want map[int64]bool
|
||||||
|
}{
|
||||||
|
{"", map[int64]bool{}},
|
||||||
|
{"12345", map[int64]bool{12345: true}},
|
||||||
|
{"12345,67890", map[int64]bool{12345: true, 67890: true}},
|
||||||
|
{" 12345 , 67890 ", map[int64]bool{12345: true, 67890: true}},
|
||||||
|
{"12345,", map[int64]bool{12345: true}},
|
||||||
|
{",12345", map[int64]bool{12345: true}},
|
||||||
|
{"invalid", map[int64]bool{}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := parseAllowedUsers(tt.input)
|
||||||
|
if len(got) != len(tt.want) {
|
||||||
|
t.Errorf("parseAllowedUsers(%q) = %v, want %v", tt.input, got, tt.want)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for k, v := range tt.want {
|
||||||
|
if got[k] != v {
|
||||||
|
t.Errorf("parseAllowedUsers(%q)[%d] = %v, want %v", tt.input, k, got[k], v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEnvDefault(t *testing.T) {
|
||||||
|
t.Setenv("TEST_EXISTS", "hello")
|
||||||
|
if got := getEnvDefault("TEST_EXISTS", "fallback"); got != "hello" {
|
||||||
|
t.Errorf("getEnvDefault() = %q, want %q", got, "hello")
|
||||||
|
}
|
||||||
|
if got := getEnvDefault("TEST_MISSING", "fallback"); got != "fallback" {
|
||||||
|
t.Errorf("getEnvDefault() = %q, want %q", got, "fallback")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetEnvIntDefault(t *testing.T) {
|
||||||
|
t.Setenv("TEST_INT", "42")
|
||||||
|
if got := getEnvIntDefault("TEST_INT", 0); got != 42 {
|
||||||
|
t.Errorf("getEnvIntDefault() = %d, want %d", got, 42)
|
||||||
|
}
|
||||||
|
if got := getEnvIntDefault("TEST_MISSING", 99); got != 99 {
|
||||||
|
t.Errorf("getEnvIntDefault() = %d, want %d", got, 99)
|
||||||
|
}
|
||||||
|
if got := getEnvIntDefault("TEST_INT", 0); got != 42 {
|
||||||
|
t.Errorf("getEnvIntDefault() cached = %d, want %d", got, 42)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,11 +7,9 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
|
|
||||||
tgbot "github.com/go-telegram/bot"
|
tgbot "github.com/go-telegram/bot"
|
||||||
|
|
||||||
"uptodownBot/internal/handler"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func startWebhook(ctx context.Context, b *tgbot.Bot, h *handler.Handler, webhookURL string) {
|
func startWebhook(ctx context.Context, b *tgbot.Bot, webhookURL string) {
|
||||||
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
|
if _, err := b.DeleteWebhook(ctx, &tgbot.DeleteWebhookParams{}); err != nil {
|
||||||
slog.Warn("failed to delete webhook", "error", err)
|
slog.Warn("failed to delete webhook", "error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,35 @@ func TestParseProgressLine(t *testing.T) {
|
|||||||
line: "some random log line",
|
line: "some random log line",
|
||||||
hasRes: false,
|
hasRes: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
line: "[download] 0.0% of 1.00GiB at 0.00B/s ETA 00:00",
|
||||||
|
pct: 0.0,
|
||||||
|
speed: "0.00B/s",
|
||||||
|
eta: "00:00",
|
||||||
|
hasRes: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
line: "[download] 0.0% of 1.00KiB at 100.00KiB/s ETA 00:01",
|
||||||
|
pct: 0.0,
|
||||||
|
speed: "100.00KiB/s",
|
||||||
|
eta: "00:01",
|
||||||
|
hasRes: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
line: "[download] 50.5% of 2.00MiB at 500.00KiB/s ETA 00:02",
|
||||||
|
pct: 50.5,
|
||||||
|
speed: "500.00KiB/s",
|
||||||
|
eta: "00:02",
|
||||||
|
hasRes: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
line: "[download] 1.2% of unknown size at 0.00B/s ETA Unknown",
|
||||||
|
hasRes: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
line: "[Merge] Merging video and audio...",
|
||||||
|
hasRes: false,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
got := parseProgressLine(tt.line)
|
got := parseProgressLine(tt.line)
|
||||||
@@ -70,3 +99,23 @@ func TestParseProgressLine(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseProgressLineEdgeCases(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
line string
|
||||||
|
name string
|
||||||
|
hasRes bool
|
||||||
|
}{
|
||||||
|
{"[download] 0.0% of 1.00GiB at 0.00B/s ETA 00:00", "multiple spaces", true},
|
||||||
|
{"[download] 100.0% of 1.00GiB at 999.99MiB/s ETA 00:00", "high speed", true},
|
||||||
|
{"[download] 100.0% of 999.99TiB at 1.00TiB/s ETA 00:00", "large numbers", true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := parseProgressLine(tt.line)
|
||||||
|
if tt.hasRes && got == nil {
|
||||||
|
t.Errorf("parseProgressLine(%q) = nil, want result", tt.line)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -49,6 +49,8 @@ type ytdlpFormat struct {
|
|||||||
|
|
||||||
type ytdlpInfo struct {
|
type ytdlpInfo struct {
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
|
Uploader string `json:"uploader"`
|
||||||
|
Thumbnail string `json:"thumbnail"`
|
||||||
Duration float64 `json:"duration"`
|
Duration float64 `json:"duration"`
|
||||||
IsLive bool `json:"is_live"`
|
IsLive bool `json:"is_live"`
|
||||||
Formats []ytdlpFormat `json:"formats"`
|
Formats []ytdlpFormat `json:"formats"`
|
||||||
@@ -95,6 +97,8 @@ func (c *Client) GetMediaInfo(url string) (*domain.MediaInfo, error) {
|
|||||||
mi := &domain.MediaInfo{
|
mi := &domain.MediaInfo{
|
||||||
URL: url,
|
URL: url,
|
||||||
Title: info.Title,
|
Title: info.Title,
|
||||||
|
Uploader: info.Uploader,
|
||||||
|
Thumbnail: info.Thumbnail,
|
||||||
Duration: info.Duration,
|
Duration: info.Duration,
|
||||||
IsPlaylist: info.Playlist != "",
|
IsPlaylist: info.Playlist != "",
|
||||||
}
|
}
|
||||||
@@ -146,6 +150,8 @@ func (c *Client) GetPlaylistInfo(url string) (*domain.MediaInfo, error) {
|
|||||||
mi := &domain.MediaInfo{
|
mi := &domain.MediaInfo{
|
||||||
URL: url,
|
URL: url,
|
||||||
Title: info.Title,
|
Title: info.Title,
|
||||||
|
Uploader: info.Uploader,
|
||||||
|
Thumbnail: info.Thumbnail,
|
||||||
IsPlaylist: true,
|
IsPlaylist: true,
|
||||||
PlaylistCount: info.PlaylistCount,
|
PlaylistCount: info.PlaylistCount,
|
||||||
PlaylistEntries: c.parsePlaylistEntries(info.Entries),
|
PlaylistEntries: c.parsePlaylistEntries(info.Entries),
|
||||||
@@ -226,8 +232,8 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output template
|
// Output template with job ID prefix for reliable file finding
|
||||||
outputPath := filepath.Join(downloadDir, "%(id)s.%(ext)s")
|
outputPath := filepath.Join(downloadDir, job.ID+"_%(id)s.%(ext)s")
|
||||||
args = append(args, "--output", outputPath)
|
args = append(args, "--output", outputPath)
|
||||||
|
|
||||||
// Progress reporting
|
// Progress reporting
|
||||||
@@ -267,7 +273,6 @@ func (c *Client) StartDownload(job *domain.DownloadJob, downloadDir string) (<-c
|
|||||||
go func() {
|
go func() {
|
||||||
defer func() {
|
defer func() {
|
||||||
if job.Status != domain.StatusCancelled {
|
if job.Status != domain.StatusCancelled {
|
||||||
job.Status = domain.StatusCompleted
|
|
||||||
updates <- job
|
updates <- job
|
||||||
}
|
}
|
||||||
close(updates)
|
close(updates)
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ type Format struct {
|
|||||||
type MediaInfo struct {
|
type MediaInfo struct {
|
||||||
URL string
|
URL string
|
||||||
Title string
|
Title string
|
||||||
|
Uploader string
|
||||||
|
Thumbnail string
|
||||||
Duration float64
|
Duration float64
|
||||||
Formats []Format
|
Formats []Format
|
||||||
Languages []string
|
Languages []string
|
||||||
@@ -54,6 +56,16 @@ type MediaInfo struct {
|
|||||||
EstimatedSize int64
|
EstimatedSize int64
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// IsAudioOnlyContent returns true when no format carries a video stream.
|
||||||
|
func (m *MediaInfo) IsAudioOnlyContent() bool {
|
||||||
|
for _, f := range m.Formats {
|
||||||
|
if f.HasVideo {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return len(m.Formats) > 0
|
||||||
|
}
|
||||||
|
|
||||||
// PlaylistEntry represents a single entry in a playlist.
|
// PlaylistEntry represents a single entry in a playlist.
|
||||||
type PlaylistEntry struct {
|
type PlaylistEntry struct {
|
||||||
ID string
|
ID string
|
||||||
|
|||||||
90
internal/domain/types_test.go
Normal file
90
internal/domain/types_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package domain
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestMediaTypeString(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
mt MediaType
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{MediaTypeAudio, "audio"},
|
||||||
|
{MediaTypeVideo, "video"},
|
||||||
|
{MediaTypeVideoAudio, "video_audio"},
|
||||||
|
{MediaType(0), "unknown"},
|
||||||
|
{MediaType(99), "unknown"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
if got := tt.mt.String(); got != tt.want {
|
||||||
|
t.Errorf("MediaType(%d).String() = %q, want %q", tt.mt, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsAudioOnlyContent(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
media *MediaInfo
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty formats returns false",
|
||||||
|
media: &MediaInfo{Formats: nil},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "all audio formats returns true",
|
||||||
|
media: &MediaInfo{
|
||||||
|
Formats: []Format{
|
||||||
|
{ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true},
|
||||||
|
{ID: "2", HasVideo: false, HasAudio: true, IsAudioOnly: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "mixed formats returns false",
|
||||||
|
media: &MediaInfo{
|
||||||
|
Formats: []Format{
|
||||||
|
{ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true},
|
||||||
|
{ID: "2", HasVideo: true, HasAudio: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single format with no video returns true",
|
||||||
|
media: &MediaInfo{
|
||||||
|
Formats: []Format{
|
||||||
|
{ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "format with both video and audio returns false",
|
||||||
|
media: &MediaInfo{
|
||||||
|
Formats: []Format{
|
||||||
|
{ID: "1", HasVideo: true, HasAudio: true},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "only audio and no-video-no-audio formats returns true",
|
||||||
|
media: &MediaInfo{
|
||||||
|
Formats: []Format{
|
||||||
|
{ID: "1", HasVideo: false, HasAudio: true, IsAudioOnly: true},
|
||||||
|
{ID: "2", HasVideo: false, HasAudio: false},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
want: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
if got := tt.media.IsAudioOnlyContent(); got != tt.want {
|
||||||
|
t.Errorf("IsAudioOnlyContent() = %v, want %v", got, tt.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,9 +4,11 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tgbot "github.com/go-telegram/bot"
|
tgbot "github.com/go-telegram/bot"
|
||||||
"github.com/go-telegram/bot/models"
|
"github.com/go-telegram/bot/models"
|
||||||
@@ -111,9 +113,36 @@ func (h *Handler) handleURLInput(ctx context.Context, chatID int64, text string,
|
|||||||
Step: 0,
|
Step: 0,
|
||||||
}
|
}
|
||||||
h.setUserStepState(chatID, ss)
|
h.setUserStepState(chatID, ss)
|
||||||
h.editText(ctx, chatID, 0, fmt.Sprintf("URL: %s\nTitle: %s\n\nSelect media type:", url, info.Title), &models.InlineKeyboardMarkup{
|
|
||||||
InlineKeyboard: mediaTypeKeyboard().InlineKeyboard,
|
// Show thumbnail and metadata preview
|
||||||
})
|
h.sendMediaPreview(ctx, chatID, info)
|
||||||
|
|
||||||
|
if info.IsAudioOnlyContent() {
|
||||||
|
// Audio-only sites: skip type selection, go straight to quality
|
||||||
|
ss.MediaType = domain.MediaTypeAudio
|
||||||
|
ss.Step = 1
|
||||||
|
filtered := dl.FilterFormatsByType(info.Formats, domain.MediaTypeAudio)
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
h.sendText(ctx, chatID, "No audio formats available for this URL.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
deduped := dl.DeduplicateResolutions(filtered)
|
||||||
|
ss.FormatIDs = make([]string, len(deduped))
|
||||||
|
labels := make([]string, len(deduped))
|
||||||
|
for i, f := range deduped {
|
||||||
|
ss.FormatIDs[i] = f.ID
|
||||||
|
labels[i] = formatLabelForDisplay(f)
|
||||||
|
}
|
||||||
|
ss.FormatIDs = append([]string{"best"}, ss.FormatIDs...)
|
||||||
|
labels = append([]string{"Best"}, labels...)
|
||||||
|
h.setUserStepState(chatID, ss)
|
||||||
|
kb := formatKeyboard("quality_", labels)
|
||||||
|
h.sendWithKB(ctx, chatID, "Select audio quality:", kb)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
kb := mediaTypeKeyboard()
|
||||||
|
h.sendWithKB(ctx, chatID, fmt.Sprintf("Title: %s\n\nSelect media type:", info.Title), kb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleTypeSelection processes the media type choice.
|
// handleTypeSelection processes the media type choice.
|
||||||
@@ -253,11 +282,11 @@ func (h *Handler) handleContainerSelection(ctx context.Context, chatID int64, ms
|
|||||||
h.clearStepState(chatID)
|
h.clearStepState(chatID)
|
||||||
|
|
||||||
// Start download in background
|
// Start download in background
|
||||||
go h.runDownload(ctx, job, msgID)
|
go h.runDownload(context.Background(), job)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDownload manages the download lifecycle, progress updates, and completion.
|
// runDownload manages the download lifecycle, progress updates, and completion.
|
||||||
func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob, msgID int) {
|
func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob) {
|
||||||
// Show initial progress message
|
// Show initial progress message
|
||||||
kb := progressKeyboard(0, "", "")
|
kb := progressKeyboard(0, "", "")
|
||||||
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
msg, err := h.Bot.SendMessage(ctx, &tgbot.SendMessageParams{
|
||||||
@@ -329,21 +358,20 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob, msgI
|
|||||||
h.mu.Unlock()
|
h.mu.Unlock()
|
||||||
|
|
||||||
if job.Status == domain.StatusCompleted {
|
if job.Status == domain.StatusCompleted {
|
||||||
// Find the downloaded file
|
// Find the downloaded file using job ID prefix
|
||||||
files, err := filepath.Glob(filepath.Join(h.downloadDir, "*"))
|
pattern := filepath.Join(h.downloadDir, job.ID+"_*")
|
||||||
if err == nil {
|
files, err := filepath.Glob(pattern)
|
||||||
for _, f := range files {
|
if err == nil && len(files) > 0 {
|
||||||
fileInfo, err := os.Stat(f)
|
f := files[0] // take the first match
|
||||||
if err != nil {
|
fileInfo, err := os.Stat(f)
|
||||||
continue
|
if err == nil {
|
||||||
}
|
|
||||||
if fileInfo.Size() > h.maxFileSize {
|
if fileInfo.Size() > h.maxFileSize {
|
||||||
h.editText(ctx, job.ChatID, job.MessageID,
|
h.editText(ctx, job.ChatID, job.MessageID,
|
||||||
fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileInfo.Size()), formatBytes(h.maxFileSize)), nil)
|
fmt.Sprintf("File too large (%s). Max: %s", formatBytes(fileInfo.Size()), formatBytes(h.maxFileSize)), nil)
|
||||||
os.Remove(f)
|
os.Remove(f)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Check file size quota after download
|
|
||||||
if !job.QuotaApplied && job.FileSize > 0 {
|
if !job.QuotaApplied && job.FileSize > 0 {
|
||||||
job.QuotaApplied = true
|
job.QuotaApplied = true
|
||||||
h.applyQuota(job.UserID, job.FileSize)
|
h.applyQuota(job.UserID, job.FileSize)
|
||||||
@@ -353,7 +381,6 @@ func (h *Handler) runDownload(ctx context.Context, job *domain.DownloadJob, msgI
|
|||||||
h.sendDocument(ctx, job.ChatID, f, job.Title)
|
h.sendDocument(ctx, job.ChatID, f, job.Title)
|
||||||
os.Remove(f)
|
os.Remove(f)
|
||||||
|
|
||||||
// Save to history
|
|
||||||
_ = h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{
|
_ = h.Repo.AddHistory(job.UserID, &domain.DownloadRecord{
|
||||||
URL: job.URL,
|
URL: job.URL,
|
||||||
Title: job.Title,
|
Title: job.Title,
|
||||||
@@ -425,6 +452,12 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c
|
|||||||
h.clearStepState(chatID)
|
h.clearStepState(chatID)
|
||||||
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
||||||
case 0:
|
case 0:
|
||||||
|
if ss.MediaInfo != nil && ss.MediaInfo.IsAudioOnlyContent() {
|
||||||
|
// Audio-only: go back to URL input directly
|
||||||
|
h.clearStepState(chatID)
|
||||||
|
h.handleDownloadPrompt(ctx, chatID, msgID, cbID, userID)
|
||||||
|
return
|
||||||
|
}
|
||||||
h.editText(ctx, chatID, msgID, "Select media type:", &models.InlineKeyboardMarkup{
|
h.editText(ctx, chatID, msgID, "Select media type:", &models.InlineKeyboardMarkup{
|
||||||
InlineKeyboard: mediaTypeKeyboard().InlineKeyboard,
|
InlineKeyboard: mediaTypeKeyboard().InlineKeyboard,
|
||||||
})
|
})
|
||||||
@@ -450,9 +483,60 @@ func (h *Handler) handleBackStep(ctx context.Context, chatID int64, msgID int, c
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleDownloadCommand handles the /download command, prompting for a URL.
|
||||||
|
func (h *Handler) handleDownloadCommand(ctx context.Context, msg *models.Message, userID int64) {
|
||||||
|
h.promptForInput(ctx, msg.Chat.ID, 0, userID, PendingURL,
|
||||||
|
"Send me the URL to download:", nil)
|
||||||
|
}
|
||||||
|
|
||||||
// handleDownloadPrompt shows the download prompt (asks for URL).
|
// handleDownloadPrompt shows the download prompt (asks for URL).
|
||||||
func (h *Handler) handleDownloadPrompt(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
|
func (h *Handler) handleDownloadPrompt(ctx context.Context, chatID int64, msgID int, cbID string, userID int64) {
|
||||||
defer h.answerCb(ctx, cbID)
|
|
||||||
h.promptForInput(ctx, chatID, msgID, userID, PendingURL,
|
h.promptForInput(ctx, chatID, msgID, userID, PendingURL,
|
||||||
"Send me the URL to download:", nil)
|
"Send me the URL to download:", nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendMediaPreview sends a thumbnail and metadata info before the selection flow.
|
||||||
|
func (h *Handler) sendMediaPreview(ctx context.Context, chatID int64, info *domain.MediaInfo) {
|
||||||
|
caption := info.Title
|
||||||
|
if info.Uploader != "" || info.Duration > 0 {
|
||||||
|
caption += "\n"
|
||||||
|
if info.Uploader != "" {
|
||||||
|
caption += fmt.Sprintf("Channel: %s", info.Uploader)
|
||||||
|
}
|
||||||
|
if info.Duration > 0 {
|
||||||
|
if info.Uploader != "" {
|
||||||
|
caption += " | "
|
||||||
|
}
|
||||||
|
caption += fmt.Sprintf("Duration: %s", formatDuration(info.Duration))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if info.Thumbnail != "" {
|
||||||
|
photo := &models.InputFileString{Data: info.Thumbnail}
|
||||||
|
_, err := h.Bot.SendPhoto(ctx, &tgbot.SendPhotoParams{
|
||||||
|
ChatID: chatID,
|
||||||
|
Photo: photo,
|
||||||
|
Caption: caption,
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Warn("send thumbnail failed, falling back to text", "error", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.sendText(ctx, chatID, caption)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatDuration(seconds float64) string {
|
||||||
|
if seconds <= 0 {
|
||||||
|
return "0:00"
|
||||||
|
}
|
||||||
|
d := time.Duration(seconds) * time.Second
|
||||||
|
h := int(d.Hours())
|
||||||
|
m := int(math.Mod(d.Minutes(), 60))
|
||||||
|
s := int(math.Mod(d.Seconds(), 60))
|
||||||
|
if h > 0 {
|
||||||
|
return fmt.Sprintf("%d:%02d:%02d", h, m, s)
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%d:%02d", m, s)
|
||||||
|
}
|
||||||
|
|||||||
@@ -182,6 +182,8 @@ func (h *Handler) routeCommand(ctx context.Context, msg *models.Message, userID
|
|||||||
h.handleHelp(ctx, msg)
|
h.handleHelp(ctx, msg)
|
||||||
case "/history":
|
case "/history":
|
||||||
h.handleHistory(ctx, msg, userID)
|
h.handleHistory(ctx, msg, userID)
|
||||||
|
case "/download":
|
||||||
|
h.handleDownloadCommand(ctx, msg, userID)
|
||||||
default:
|
default:
|
||||||
h.sendText(ctx, msg.Chat.ID, "Unknown command")
|
h.sendText(ctx, msg.Chat.ID, "Unknown command")
|
||||||
}
|
}
|
||||||
@@ -222,6 +224,7 @@ func (h *Handler) HandleCallback(ctx context.Context, cb *models.CallbackQuery)
|
|||||||
h.backToMenu(ctx, chatID, msgID, cb.ID, userID)
|
h.backToMenu(ctx, chatID, msgID, cb.ID, userID)
|
||||||
case data == "download":
|
case data == "download":
|
||||||
h.handleDownloadPrompt(ctx, chatID, msgID, cb.ID, userID)
|
h.handleDownloadPrompt(ctx, chatID, msgID, cb.ID, userID)
|
||||||
|
h.answerCb(ctx, cb.ID)
|
||||||
case data == "settings":
|
case data == "settings":
|
||||||
h.settingsCallback(ctx, chatID, msgID, cb.ID, userID)
|
h.settingsCallback(ctx, chatID, msgID, cb.ID, userID)
|
||||||
case data == "help":
|
case data == "help":
|
||||||
|
|||||||
84
internal/handler/handler_test.go
Normal file
84
internal/handler/handler_test.go
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"uptodownBot/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFormatBytes(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input int64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{0, "0B"},
|
||||||
|
{1, "1B"},
|
||||||
|
{1023, "1023B"},
|
||||||
|
{1024, "1.0KB"},
|
||||||
|
{1536, "1.5KB"},
|
||||||
|
{1048576, "1.0MB"},
|
||||||
|
{1073741824, "1.0GB"},
|
||||||
|
{1099511627776, "1.0TB"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := formatBytes(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formatBytes(%d) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatDuration(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input float64
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{0, "0:00"},
|
||||||
|
{5, "0:05"},
|
||||||
|
{60, "1:00"},
|
||||||
|
{65, "1:05"},
|
||||||
|
{3600, "1:00:00"},
|
||||||
|
{3661, "1:01:01"},
|
||||||
|
{86400, "24:00:00"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := formatDuration(tt.input)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formatDuration(%f) = %q, want %q", tt.input, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFormatLabelForDisplay(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
f domain.Format
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
f: domain.Format{Resolution: "1920x1080", Filesize: 1048576},
|
||||||
|
want: "1920x1080 (1.0MB)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
f: domain.Format{Resolution: "1920x1080"},
|
||||||
|
want: "1920x1080",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
f: domain.Format{Bitrate: "128k", Filesize: 1048576},
|
||||||
|
want: "128k (1.0MB)",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
f: domain.Format{Bitrate: "128k"},
|
||||||
|
want: "128k",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
f: domain.Format{ID: "137"},
|
||||||
|
want: "137",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := formatLabelForDisplay(tt.f)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("formatLabelForDisplay(%+v) = %q, want %q", tt.f, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -128,40 +128,6 @@ func progressKeyboard(pct float64, speed, eta string) models.InlineKeyboardMarku
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildToggleKeyboard creates an on/off picker for a boolean setting.
|
|
||||||
// Matches worktimeBot's pattern with "> " prefix for the current selection.
|
|
||||||
func buildToggleKeyboard(userID int64, setting, label string, st *domain.UserPreferences) (string, models.InlineKeyboardMarkup) {
|
|
||||||
current := false
|
|
||||||
switch setting {
|
|
||||||
case "quality":
|
|
||||||
current = st.DefaultQuality == "best"
|
|
||||||
case "container":
|
|
||||||
current = st.DefaultContainer == "mp4"
|
|
||||||
}
|
|
||||||
options := []struct {
|
|
||||||
label string
|
|
||||||
data string
|
|
||||||
value bool
|
|
||||||
}{
|
|
||||||
{"Enabled", setting + "_enable", true},
|
|
||||||
{"Disabled", setting + "_disable", false},
|
|
||||||
}
|
|
||||||
rows := [][]models.InlineKeyboardButton{}
|
|
||||||
for _, opt := range options {
|
|
||||||
l := opt.label
|
|
||||||
if opt.value == current {
|
|
||||||
l = "> " + l
|
|
||||||
}
|
|
||||||
rows = append(rows, []models.InlineKeyboardButton{
|
|
||||||
{Text: l, CallbackData: opt.data},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
rows = append(rows, []models.InlineKeyboardButton{
|
|
||||||
{Text: "Back to Settings", CallbackData: "back_settings"},
|
|
||||||
})
|
|
||||||
return label + ":", models.InlineKeyboardMarkup{InlineKeyboard: rows}
|
|
||||||
}
|
|
||||||
|
|
||||||
// playlistNavKeyboard builds the paginated navigation for playlist selection.
|
// playlistNavKeyboard builds the paginated navigation for playlist selection.
|
||||||
func playlistNavKeyboard(state *domain.PlaylistState, selectedCount int) models.InlineKeyboardMarkup {
|
func playlistNavKeyboard(state *domain.PlaylistState, selectedCount int) models.InlineKeyboardMarkup {
|
||||||
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
|
totalPages := (len(state.Entries) + state.PerPage - 1) / state.PerPage
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ func (h *Handler) handlePlaylist(ctx context.Context, chatID int64, userID int64
|
|||||||
}
|
}
|
||||||
h.setUserStepState(chatID, ss)
|
h.setUserStepState(chatID, ss)
|
||||||
|
|
||||||
|
h.sendMediaPreview(ctx, chatID, info)
|
||||||
|
|
||||||
kb := playlistNavKeyboard(state, 0)
|
kb := playlistNavKeyboard(state, 0)
|
||||||
h.sendWithKB(ctx, chatID, fmt.Sprintf("Playlist: %s (%d videos)\n\nSelect videos to download:", info.Title, info.PlaylistCount), kb)
|
h.sendWithKB(ctx, chatID, fmt.Sprintf("Playlist: %s (%d videos)\n\nSelect videos to download:", info.Title, info.PlaylistCount), kb)
|
||||||
}
|
}
|
||||||
@@ -169,7 +171,7 @@ func (h *Handler) handlePlaylistConfirm(ctx context.Context, chatID int64, msgID
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.setActiveJob(userID, job)
|
h.setActiveJob(userID, job)
|
||||||
h.runDownload(ctx, job, 0)
|
h.runDownload(context.Background(), job)
|
||||||
|
|
||||||
// Wait for active job to complete
|
// Wait for active job to complete
|
||||||
for {
|
for {
|
||||||
|
|||||||
214
internal/repo/store_test.go
Normal file
214
internal/repo/store_test.go
Normal file
@@ -0,0 +1,214 @@
|
|||||||
|
package repo
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"uptodownBot/internal/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
func newTestStore(t *testing.T) *Store {
|
||||||
|
t.Helper()
|
||||||
|
s, err := NewStore("file::memory:?cache=shared")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NewStore() failed: %v", err)
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetOrCreateUser(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
id, err := s.GetOrCreateUser(12345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateUser() failed: %v", err)
|
||||||
|
}
|
||||||
|
if id == 0 {
|
||||||
|
t.Fatal("GetOrCreateUser() returned 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call should return same ID
|
||||||
|
id2, err := s.GetOrCreateUser(12345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateUser() second call failed: %v", err)
|
||||||
|
}
|
||||||
|
if id != id2 {
|
||||||
|
t.Errorf("GetOrCreateUser() returned different IDs: %d vs %d", id, id2)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Different chat_id returns different user ID
|
||||||
|
id3, err := s.GetOrCreateUser(67890)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateUser() third call failed: %v", err)
|
||||||
|
}
|
||||||
|
if id3 == id {
|
||||||
|
t.Error("GetOrCreateUser() should return different IDs for different chat_ids")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPreferences(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
userID, err := s.GetOrCreateUser(12345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateUser() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs, err := s.GetOrCreatePreferences(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreatePreferences() failed: %v", err)
|
||||||
|
}
|
||||||
|
if prefs.DefaultMediaType != domain.MediaTypeVideoAudio {
|
||||||
|
t.Errorf("default media type = %v, want VideoAudio", prefs.DefaultMediaType)
|
||||||
|
}
|
||||||
|
|
||||||
|
prefs.DefaultMediaType = domain.MediaTypeAudio
|
||||||
|
prefs.DefaultQuality = "best"
|
||||||
|
prefs.DefaultContainer = "mp3"
|
||||||
|
prefs.DefaultLanguage = "en"
|
||||||
|
if err := s.UpdatePreferences(userID, prefs); err != nil {
|
||||||
|
t.Fatalf("UpdatePreferences() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetOrCreatePreferences(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreatePreferences() after update failed: %v", err)
|
||||||
|
}
|
||||||
|
if got.DefaultMediaType != domain.MediaTypeAudio {
|
||||||
|
t.Errorf("media type = %v, want Audio", got.DefaultMediaType)
|
||||||
|
}
|
||||||
|
if got.DefaultQuality != "best" {
|
||||||
|
t.Errorf("quality = %q, want best", got.DefaultQuality)
|
||||||
|
}
|
||||||
|
if got.DefaultContainer != "mp3" {
|
||||||
|
t.Errorf("container = %q, want mp3", got.DefaultContainer)
|
||||||
|
}
|
||||||
|
if got.DefaultLanguage != "en" {
|
||||||
|
t.Errorf("language = %q, want en", got.DefaultLanguage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuotas(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
userID, err := s.GetOrCreateUser(12345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateUser() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
q, err := s.GetOrCreateQuotas(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateQuotas() failed: %v", err)
|
||||||
|
}
|
||||||
|
if q.DailyLimitMB != domain.DefaultDailyQuotaMB {
|
||||||
|
t.Errorf("daily limit = %d, want %d", q.DailyLimitMB, domain.DefaultDailyQuotaMB)
|
||||||
|
}
|
||||||
|
|
||||||
|
q.DailyUsedMB = 50
|
||||||
|
q.WeeklyUsedMB = 200
|
||||||
|
q.MonthlyUsedMB = 500
|
||||||
|
if err := s.UpdateQuotas(userID, q); err != nil {
|
||||||
|
t.Fatalf("UpdateQuotas() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := s.GetOrCreateQuotas(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateQuotas() after update failed: %v", err)
|
||||||
|
}
|
||||||
|
if got.DailyUsedMB != 50 {
|
||||||
|
t.Errorf("daily used = %d, want 50", got.DailyUsedMB)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistory(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
userID, err := s.GetOrCreateUser(12345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateUser() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rec := &domain.DownloadRecord{
|
||||||
|
URL: "https://example.com/video",
|
||||||
|
Title: "Test Video",
|
||||||
|
MediaType: "video",
|
||||||
|
FormatID: "137",
|
||||||
|
Container: "mp4",
|
||||||
|
FileSize: 1048576,
|
||||||
|
Status: "completed",
|
||||||
|
}
|
||||||
|
if err := s.AddHistory(userID, rec); err != nil {
|
||||||
|
t.Fatalf("AddHistory() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
records, err := s.GetHistory(userID, 10, 0)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory() failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(records) != 1 {
|
||||||
|
t.Fatalf("GetHistory() returned %d records, want 1", len(records))
|
||||||
|
}
|
||||||
|
if records[0].URL != rec.URL {
|
||||||
|
t.Errorf("record URL = %q, want %q", records[0].URL, rec.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := s.GetHistoryCount(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistoryCount() failed: %v", err)
|
||||||
|
}
|
||||||
|
if count != 1 {
|
||||||
|
t.Errorf("history count = %d, want 1", count)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test pagination
|
||||||
|
records2, err := s.GetHistory(userID, 10, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistory() with offset failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(records2) != 0 {
|
||||||
|
t.Errorf("GetHistory() with offset 1 returned %d records, want 0", len(records2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDeleteUserData(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
defer s.Close()
|
||||||
|
|
||||||
|
userID, err := s.GetOrCreateUser(12345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateUser() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = s.GetOrCreatePreferences(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreatePreferences() failed: %v", err)
|
||||||
|
}
|
||||||
|
_, err = s.GetOrCreateQuotas(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateQuotas() failed: %v", err)
|
||||||
|
}
|
||||||
|
s.AddHistory(userID, &domain.DownloadRecord{
|
||||||
|
URL: "https://example.com", Title: "Test", Status: "completed",
|
||||||
|
})
|
||||||
|
|
||||||
|
if err := s.DeleteUserData(userID); err != nil {
|
||||||
|
t.Fatalf("DeleteUserData() failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify user no longer exists
|
||||||
|
_, err = s.GetOrCreateUser(12345)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetOrCreateUser() after delete failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
count, err := s.GetHistoryCount(userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetHistoryCount() after delete failed: %v", err)
|
||||||
|
}
|
||||||
|
if count != 0 {
|
||||||
|
t.Errorf("history count after delete = %d, want 0", count)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user