2448 lines
92 KiB
Go
2448 lines
92 KiB
Go
package cli
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
goruntime "runtime"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/bwmarrin/discordgo"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/openclaw/discrawl/internal/config"
|
|
discordclient "github.com/openclaw/discrawl/internal/discord"
|
|
"github.com/openclaw/discrawl/internal/discorddesktop"
|
|
"github.com/openclaw/discrawl/internal/report"
|
|
"github.com/openclaw/discrawl/internal/share"
|
|
"github.com/openclaw/discrawl/internal/store"
|
|
"github.com/openclaw/discrawl/internal/syncer"
|
|
)
|
|
|
|
func TestHelpAndVersion(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(context.Background(), []string{"help"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "discrawl")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(context.Background(), []string{"--version"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "0.7.0")
|
|
|
|
err := Run(context.Background(), []string{"bogus"}, &out, &bytes.Buffer{})
|
|
require.Equal(t, 2, ExitCode(err))
|
|
require.Equal(t, 1, ExitCode(context.Canceled))
|
|
require.Equal(t, 7, ExitCode(&cliError{code: 7, err: errors.New("custom")}))
|
|
}
|
|
|
|
func TestCommandValidationEdges(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Discord.TokenSource = "none"
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.Close())
|
|
|
|
cases := [][]string{
|
|
{"--config", cfgPath, "--bogus"},
|
|
{"--config", cfgPath, "search"},
|
|
{"--config", cfgPath, "search", "--mode", "bogus", "term"},
|
|
{"--config", cfgPath, "messages"},
|
|
{"--config", cfgPath, "messages", "--hours", "-1", "--channel", "general"},
|
|
{"--config", cfgPath, "messages", "--hours", "1", "--days", "1", "--channel", "general"},
|
|
{"--config", cfgPath, "messages", "--all", "--last", "1", "--channel", "general"},
|
|
{"--config", cfgPath, "messages", "--dm", "--sync", "--channel", "alice"},
|
|
{"--config", cfgPath, "dms", "--hours", "-1"},
|
|
{"--config", cfgPath, "dms", "--limit", "1", "--last", "1", "--with", "alice"},
|
|
{"--config", cfgPath, "mentions"},
|
|
{"--config", cfgPath, "mentions", "--days", "-1", "--target", "u1"},
|
|
{"--config", cfgPath, "mentions", "--type", "channel", "--target", "u1"},
|
|
{"--config", cfgPath, "digest", "--since", "-1d"},
|
|
{"--config", cfgPath, "analytics", "wat"},
|
|
{"--config", cfgPath, "analytics", "quiet", "extra"},
|
|
{"--config", cfgPath, "analytics", "trends", "--weeks", "-1"},
|
|
{"--config", cfgPath, "channels"},
|
|
{"--config", cfgPath, "channels", "wat"},
|
|
{"--config", cfgPath, "channels", "show"},
|
|
{"--config", cfgPath, "status", "extra"},
|
|
{"--config", cfgPath, "report", "extra"},
|
|
{"--config", cfgPath, "wiretap", "extra"},
|
|
{"--config", cfgPath, "wiretap", "--max-file-bytes", "0"},
|
|
{"--config", cfgPath, "sync", "--source", "bogus"},
|
|
{"--config", cfgPath, "sync", "--since", "not-time"},
|
|
{"--config", cfgPath, "sync", "--no-update", "--update", "force"},
|
|
{"--config", cfgPath, "publish", "--remote", ""},
|
|
{"--config", cfgPath, "subscribe"},
|
|
{"--config", cfgPath, "update", "extra"},
|
|
{"--config", cfgPath, "sql", "--confirm", "select 1"},
|
|
{"--config", cfgPath, "sql", "--unsafe", "select 1"},
|
|
{"--config", cfgPath, "members"},
|
|
{"--config", cfgPath, "members", "wat"},
|
|
}
|
|
for _, args := range cases {
|
|
var stdout, stderr bytes.Buffer
|
|
err := Run(ctx, args, &stdout, &stderr)
|
|
require.Error(t, err, args)
|
|
}
|
|
}
|
|
|
|
func TestOutputBranches(t *testing.T) {
|
|
now := time.Date(2026, 5, 8, 12, 0, 0, 0, time.UTC)
|
|
values := []any{
|
|
syncRunStats{
|
|
Source: "both",
|
|
Discord: &syncer.SyncStats{Guilds: 1, Channels: 2, Threads: 3, Members: 4, Messages: 5},
|
|
Wiretap: &discorddesktop.Stats{
|
|
Path: "/tmp/discord",
|
|
FilesVisited: 1,
|
|
FilesScanned: 2,
|
|
FilesSkipped: 3,
|
|
FilesUnchanged: 4,
|
|
CacheFilesFastSkipped: 5,
|
|
JSONObjects: 6,
|
|
Guilds: 7,
|
|
Channels: 8,
|
|
Messages: 9,
|
|
DMMessages: 10,
|
|
DMChannels: 11,
|
|
GuildMessages: 12,
|
|
SkippedMessages: 13,
|
|
SkippedChannels: 14,
|
|
Checkpoints: 15,
|
|
FullCache: true,
|
|
DryRun: true,
|
|
},
|
|
},
|
|
syncer.SyncStats{Guilds: 1, Channels: 2, Threads: 3, Members: 4, Messages: 5},
|
|
discorddesktop.Stats{Path: "/tmp/discord", FilesVisited: 1, FullCache: true, DryRun: true},
|
|
store.EmbeddingDrainStats{
|
|
Processed: 3,
|
|
Succeeded: 2,
|
|
Failed: 1,
|
|
Requeued: 4,
|
|
RateLimited: true,
|
|
RemainingBacklog: 5,
|
|
Provider: "openai",
|
|
Model: "model",
|
|
InputVersion: "v1",
|
|
},
|
|
[]store.DirectMessageConversationRow{{
|
|
ChannelID: "c1",
|
|
Name: "Alice",
|
|
MessageCount: 2,
|
|
AuthorCount: 1,
|
|
FirstMessageAt: now.Add(-time.Hour),
|
|
LastMessageAt: now,
|
|
}},
|
|
store.MemberProfile{
|
|
Member: store.MemberRow{
|
|
GuildID: "g1",
|
|
UserID: "u1",
|
|
Username: "peter",
|
|
DisplayName: "Peter",
|
|
JoinedAt: now,
|
|
XHandle: "steipete",
|
|
GitHubLogin: "steipete",
|
|
Website: "https://steipete.me",
|
|
Pronouns: "he/him",
|
|
Location: "Vienna",
|
|
Bio: "Maintainer",
|
|
URLs: []string{"https://example.com"},
|
|
},
|
|
MessageCount: 1,
|
|
FirstMessageAt: now.Add(-time.Hour),
|
|
LastMessageAt: now,
|
|
RecentMessages: []store.MessageRow{{ChannelName: "general", CreatedAt: now, Content: "hello"}},
|
|
},
|
|
report.Digest{
|
|
Since: now.Add(-24 * time.Hour),
|
|
Until: now,
|
|
WindowLabel: "1d",
|
|
Channels: []report.ChannelDigest{{
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
Kind: "text",
|
|
GuildID: "g1",
|
|
Messages: 3,
|
|
Replies: 1,
|
|
ActiveAuthors: 2,
|
|
TopPosters: []report.RankedCount{{Name: "Peter", Count: 2}},
|
|
TopMentions: []report.RankedCount{{Count: 1}},
|
|
}},
|
|
Totals: report.DigestTotals{Messages: 3, Replies: 1, Channels: 1, ActiveAuthors: 2},
|
|
},
|
|
report.Quiet{
|
|
Since: now.Add(-24 * time.Hour),
|
|
Until: now,
|
|
Channels: []report.QuietChannel{{
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
Kind: "text",
|
|
LastMessage: "",
|
|
DaysSilent: -1,
|
|
}},
|
|
Totals: report.QuietTotals{Channels: 1},
|
|
},
|
|
report.Trends{
|
|
Since: now.AddDate(0, 0, -14),
|
|
Until: now,
|
|
Weeks: 2,
|
|
Rows: []report.TrendsRow{{
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
Kind: "text",
|
|
GuildID: "g1",
|
|
Weekly: []report.WeeklyCount{
|
|
{WeekStart: now.AddDate(0, 0, -14), Messages: 1},
|
|
{WeekStart: now.AddDate(0, 0, -7), Messages: 2},
|
|
},
|
|
}},
|
|
},
|
|
map[string]any{"b": 2, "a": 1},
|
|
}
|
|
for _, value := range values {
|
|
var out bytes.Buffer
|
|
require.NoError(t, printHuman(&out, value))
|
|
require.NotEmpty(t, out.String())
|
|
}
|
|
|
|
var plain bytes.Buffer
|
|
require.NoError(t, printPlain(&plain, report.Quiet{Channels: []report.QuietChannel{{ChannelID: "c1", ChannelName: "general", Kind: "text", GuildID: "g1", LastMessage: "now", DaysSilent: 0}}}))
|
|
require.NoError(t, printPlain(&plain, report.Trends{Rows: []report.TrendsRow{{GuildID: "g1", ChannelID: "c1", ChannelName: "general", Kind: "text", Weekly: []report.WeeklyCount{{WeekStart: now, Messages: 2}}}}}))
|
|
require.Error(t, printPlain(io.Discard, struct{}{}))
|
|
require.Error(t, printHuman(io.Discard, struct{}{}))
|
|
require.Equal(t, "this is a profile field with a very l...", trimForTable("this is a profile field with a very long text value"))
|
|
}
|
|
|
|
func TestStatusSearchSQLAndListings(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.DefaultGuildID = "g1"
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{
|
|
GuildID: "g1",
|
|
UserID: "u1",
|
|
Username: "peter",
|
|
DisplayName: "Peter",
|
|
RoleIDsJSON: `[]`,
|
|
RawJSON: `{"bio":"Maintainer","github":"steipete","website":"https://steipete.me"}`,
|
|
}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m1",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "Peter",
|
|
MessageType: 0,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
Content: "panic locked database",
|
|
NormalizedContent: "panic locked database",
|
|
RawJSON: `{}`,
|
|
}))
|
|
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g2", Name: "Other Guild", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c2", GuildID: "g2", Kind: "text", Name: "random", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m-other",
|
|
GuildID: "g2",
|
|
ChannelID: "c2",
|
|
ChannelName: "random",
|
|
AuthorID: "u2",
|
|
AuthorName: "Outside",
|
|
MessageType: 0,
|
|
CreatedAt: time.Now().UTC().Add(-time.Hour).Format(time.RFC3339Nano),
|
|
Content: "outside default guild",
|
|
NormalizedContent: "outside default guild",
|
|
RawJSON: `{}`,
|
|
}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m2",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "Peter",
|
|
MessageType: 0,
|
|
CreatedAt: time.Now().UTC().Add(time.Second).Format(time.RFC3339Nano),
|
|
Content: "",
|
|
NormalizedContent: "",
|
|
RawJSON: `{"author":{"username":"Peter"}}`,
|
|
}))
|
|
require.NoError(t, s.UpsertMessages(ctx, []store.MessageMutation{{
|
|
Record: store.MessageRecord{
|
|
ID: "m3",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "Peter",
|
|
MessageType: 0,
|
|
CreatedAt: time.Now().UTC().Add(2 * time.Second).Format(time.RFC3339Nano),
|
|
Content: "",
|
|
NormalizedContent: "trace.txt stack trace line one",
|
|
HasAttachments: true,
|
|
RawJSON: `{"author":{"username":"Peter"}}`,
|
|
},
|
|
Mentions: []store.MentionEventRecord{{
|
|
MessageID: "m3",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
AuthorID: "u1",
|
|
TargetType: "user",
|
|
TargetID: "u2",
|
|
TargetName: "Shadow",
|
|
EventAt: time.Now().UTC().Add(2 * time.Second).Format(time.RFC3339Nano),
|
|
}},
|
|
}}))
|
|
require.NoError(t, s.Close())
|
|
|
|
tests := [][]string{
|
|
{"--config", cfgPath, "status"},
|
|
{"--config", cfgPath, "search", "panic"},
|
|
{"--config", cfgPath, "search", "stack"},
|
|
{"--config", cfgPath, "search", "--include-empty", "Peter"},
|
|
{"--config", cfgPath, "messages", "--channel", "general", "--days", "7", "--all"},
|
|
{"--config", cfgPath, "messages", "--channel", "general", "--days", "7", "--all", "--include-empty"},
|
|
{"--config", cfgPath, "mentions", "--target", "Shadow", "--limit", "10"},
|
|
{"--config", cfgPath, "sql", "select count(*) as total from messages"},
|
|
{"--config", cfgPath, "members", "list"},
|
|
{"--config", cfgPath, "members", "search", "Maintainer"},
|
|
{"--config", cfgPath, "members", "show", "u1"},
|
|
{"--config", cfgPath, "channels", "list"},
|
|
{"--config", cfgPath, "report"},
|
|
}
|
|
for _, args := range tests {
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, args, &out, &bytes.Buffer{}))
|
|
require.NotEmpty(t, out.String())
|
|
}
|
|
|
|
for _, args := range [][]string{
|
|
{"--config", cfgPath, "metadata", "--json"},
|
|
{"--config", cfgPath, "status", "--json"},
|
|
} {
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, args, &out, &bytes.Buffer{}))
|
|
var payload map[string]any
|
|
require.NoError(t, json.Unmarshal(out.Bytes(), &payload))
|
|
require.NotEmpty(t, payload)
|
|
}
|
|
|
|
before, err := os.ReadFile(dbPath)
|
|
require.NoError(t, err)
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "--json", "tui", "--limit", "5"}, &out, &bytes.Buffer{}))
|
|
var rows []map[string]any
|
|
require.NoError(t, json.Unmarshal(out.Bytes(), &rows))
|
|
require.NotEmpty(t, rows)
|
|
require.Equal(t, "panic locked database", rows[0]["title"])
|
|
require.Equal(t, "discord", rows[0]["source"])
|
|
require.Equal(t, "message", rows[0]["kind"])
|
|
require.Equal(t, "Guild", rows[0]["scope"])
|
|
require.Equal(t, "general", rows[0]["container"])
|
|
require.Equal(t, "https://discord.com/channels/g1/c1/m1", rows[0]["url"])
|
|
after, err := os.ReadFile(dbPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, before, after, "tui --json should not mutate the database")
|
|
}
|
|
|
|
func TestTUIHelpReturnsUsage(t *testing.T) {
|
|
var stdout bytes.Buffer
|
|
var stderr bytes.Buffer
|
|
|
|
require.NoError(t, Run(context.Background(), []string{"tui", "--help"}, &stdout, &stderr))
|
|
require.Contains(t, stdout.String(), "Usage of tui:")
|
|
require.Contains(t, stdout.String(), "-limit")
|
|
require.Contains(t, stdout.String(), "right-click")
|
|
require.Contains(t, stdout.String(), "# jump")
|
|
require.Empty(t, stderr.String())
|
|
}
|
|
|
|
func TestControlStatusIncludesShareAndFileSizes(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
require.NoError(t, os.WriteFile(dbPath, []byte("db"), 0o600))
|
|
require.NoError(t, os.WriteFile(dbPath+"-wal", []byte("wal"), 0o600))
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Share.Remote = "https://github.com/openclaw/discrawl-share.git"
|
|
cfg.Share.RepoPath = filepath.Join(dir, "share")
|
|
status := store.Status{
|
|
DBPath: dbPath,
|
|
MessageCount: 5,
|
|
ChannelCount: 2,
|
|
}
|
|
|
|
out := controlStatus(filepath.Join(dir, "config.toml"), cfg, status, true)
|
|
require.Equal(t, int64(2), out.DatabaseBytes)
|
|
require.Equal(t, int64(3), out.WALBytes)
|
|
require.Zero(t, fileSize(filepath.Join(dir, "missing.db")))
|
|
require.NotNil(t, out.Share)
|
|
require.True(t, out.Share.Enabled)
|
|
require.True(t, out.Share.NeedsUpdate)
|
|
require.Contains(t, out.Summary, "5 messages")
|
|
}
|
|
|
|
func TestFormattingAndTUISourceBranches(t *testing.T) {
|
|
require.Equal(t, "-", formatDaysSilent(-1))
|
|
require.Equal(t, "4", formatDaysSilent(4))
|
|
require.Equal(t, "0", formatWindowDuration(0))
|
|
require.Equal(t, "2d", formatWindowDuration(48*time.Hour))
|
|
require.Equal(t, "3h", formatWindowDuration(3*time.Hour))
|
|
require.Equal(t, "1h30m0s", formatWindowDuration(90*time.Minute))
|
|
require.Equal(t, 6*time.Hour, mustDuration("bogus"))
|
|
require.Equal(t, 15*time.Minute, mustDuration("15m"))
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = "/tmp/discrawl.db"
|
|
r := &runtime{cfg: cfg}
|
|
require.Equal(t, "local", r.archiveSourceKind())
|
|
require.Equal(t, cfg.DBPath, r.archiveSourceLocation())
|
|
guilds, err := r.resolveTUIGuilds(false, "", "")
|
|
require.NoError(t, err)
|
|
require.Empty(t, guilds)
|
|
|
|
r.cfg.DefaultGuildID = "guild-one"
|
|
guilds, err = r.resolveTUIGuilds(false, "", "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"guild-one"}, guilds)
|
|
|
|
r.cfg.Share.Remote = "https://github.com/openclaw/discrawl-share.git"
|
|
require.Equal(t, "remote", r.archiveSourceKind())
|
|
require.Equal(t, r.cfg.Share.Remote, r.archiveSourceLocation())
|
|
}
|
|
|
|
func TestWiretapImportsDesktopDirectMessages(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
desktopPath := filepath.Join(dir, "discord")
|
|
require.NoError(t, os.MkdirAll(filepath.Join(desktopPath, "IndexedDB"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(desktopPath, "IndexedDB", "000001.log"), []byte(`{"id":"111111111111111111","type":1,"recipients":[{"id":"222222222222222222","username":"alice","global_name":"Alice"}]}
|
|
{"id":"333333333333333333","channel_id":"111111111111111111","content":"secret DM launch plan","timestamp":"2026-04-23T18:20:43Z","author":{"id":"222222222222222222","username":"alice","global_name":"Alice"}}`), 0o600))
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Desktop.Path = desktopPath
|
|
cfg.Discord.TokenSource = "none"
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "wiretap"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "messages=1")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "launch"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "secret DM launch plan")
|
|
require.Contains(t, out.String(), "@me")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "dms"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "Alice")
|
|
require.Contains(t, out.String(), "111111111111111111")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "dms", "--with", "Alice", "--last", "1"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "secret DM launch plan")
|
|
require.Contains(t, out.String(), "@me")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "--dm", "launch"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "secret DM launch plan")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "messages", "--dm", "--channel", "Alice", "--last", "1"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "secret DM launch plan")
|
|
}
|
|
|
|
func TestDiscordTUIRowsIncludePaneMetadata(t *testing.T) {
|
|
rows := discordTUIRows([]store.MessageRow{{
|
|
MessageID: "m1",
|
|
GuildID: "@me",
|
|
GuildName: "Discord Direct Messages",
|
|
ChannelID: "c1",
|
|
ChannelName: "Vincent K",
|
|
AuthorID: "u1",
|
|
AuthorName: "Peter",
|
|
Content: "hello from desktop",
|
|
DisplayContent: "hello from Vincent",
|
|
CreatedAt: time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC),
|
|
ReplyToMessage: "m0",
|
|
HasAttachments: true,
|
|
AttachmentNames: "trace.txt",
|
|
AttachmentText: "stack trace line one",
|
|
Pinned: true,
|
|
}})
|
|
require.Len(t, rows, 1)
|
|
require.Equal(t, "hello from Vincent", rows[0].Title)
|
|
require.Contains(t, rows[0].Detail, "hello from Vincent")
|
|
require.Contains(t, rows[0].Detail, "Attachments")
|
|
require.Contains(t, rows[0].Detail, "stack trace line one")
|
|
require.Equal(t, "hello from Vincent", rows[0].Text)
|
|
require.Equal(t, "Direct messages", rows[0].Scope)
|
|
require.Equal(t, "Vincent K", rows[0].Container)
|
|
require.Contains(t, rows[0].Tags, "dm")
|
|
require.Equal(t, "true", rows[0].Fields["attachments"])
|
|
require.Equal(t, "trace.txt", rows[0].Fields["attachment_names"])
|
|
require.Equal(t, "true", rows[0].Fields["pinned"])
|
|
require.Equal(t, "m0", rows[0].Fields["reply_to"])
|
|
require.Equal(t, "@me", rows[0].Fields["guild_id"])
|
|
|
|
rows = discordTUIRows([]store.MessageRow{{
|
|
MessageID: "m2",
|
|
GuildID: "g1",
|
|
ChannelID: "c2",
|
|
AuthorID: "439223656200273932",
|
|
Content: "desktop-only author",
|
|
CreatedAt: time.Date(2026, 5, 2, 12, 0, 0, 0, time.UTC),
|
|
Source: "discord_desktop",
|
|
}})
|
|
require.Equal(t, "user:439223...3932", rows[0].Author)
|
|
require.Equal(t, "DM c2", discordContainerLabel(store.MessageRow{GuildID: "@me", ChannelID: "c2"}))
|
|
require.Contains(t, rows[0].Tags, "discord_desktop")
|
|
}
|
|
|
|
func TestParseMessageWindow(t *testing.T) {
|
|
rt := &runtime{now: func() time.Time {
|
|
return time.Date(2026, 4, 24, 12, 0, 0, 0, time.UTC)
|
|
}}
|
|
|
|
since, before, err := rt.parseMessageWindow(6, 0, "", "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, time.Date(2026, 4, 24, 6, 0, 0, 0, time.UTC), since)
|
|
require.True(t, before.IsZero())
|
|
|
|
since, before, err = rt.parseMessageWindow(0, 2, "", "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC), since)
|
|
require.True(t, before.IsZero())
|
|
|
|
since, before, err = rt.parseMessageWindow(0, 0, "2026-04-20T10:00:00Z", "2026-04-21T10:00:00Z")
|
|
require.NoError(t, err)
|
|
require.Equal(t, time.Date(2026, 4, 20, 10, 0, 0, 0, time.UTC), since)
|
|
require.Equal(t, time.Date(2026, 4, 21, 10, 0, 0, 0, time.UTC), before)
|
|
|
|
_, _, err = rt.parseMessageWindow(0, 0, "bad", "")
|
|
require.Equal(t, 2, ExitCode(err))
|
|
_, _, err = rt.parseMessageWindow(0, 0, "", "bad")
|
|
require.Equal(t, 2, ExitCode(err))
|
|
}
|
|
|
|
func TestWiretapAndSearchWorkWithoutConfig(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
home := filepath.Join(dir, "home")
|
|
desktopPath := filepath.Join(dir, "discord")
|
|
require.NoError(t, os.MkdirAll(filepath.Join(desktopPath, "IndexedDB"), 0o755))
|
|
require.NoError(t, os.MkdirAll(home, 0o755))
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
require.NoError(t, os.WriteFile(filepath.Join(desktopPath, "IndexedDB", "000001.log"), []byte(`{"id":"111111111111111112","type":1,"recipients":[{"id":"222222222222222223","username":"alice","global_name":"Alice"}]}
|
|
{"id":"333333333333333334","channel_id":"111111111111111112","content":"local-only DM import","timestamp":"2026-04-23T18:20:43Z","author":{"id":"222222222222222223","username":"alice","global_name":"Alice"}}`), 0o600))
|
|
|
|
cfgPath := filepath.Join(dir, "missing.toml")
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "wiretap", "--path", desktopPath}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "messages=1")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "local-only"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "local-only DM import")
|
|
require.Contains(t, out.String(), "@me")
|
|
}
|
|
|
|
func TestSyncWiretapSourceImportsDesktopMessages(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
desktopPath := filepath.Join(dir, "discord")
|
|
require.NoError(t, os.MkdirAll(filepath.Join(desktopPath, "IndexedDB"), 0o755))
|
|
require.NoError(t, os.WriteFile(filepath.Join(desktopPath, "IndexedDB", "000001.log"), []byte(`{"id":"111111111111111117","type":1,"recipients":[{"id":"222222222222222228","username":"alice","global_name":"Alice"}]}
|
|
{"id":"333333333333333339","channel_id":"111111111111111117","content":"sync wiretap import","timestamp":"2026-04-23T18:20:43Z","author":{"id":"222222222222222228","username":"alice","global_name":"Alice"}}`), 0o600))
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Desktop.Path = desktopPath
|
|
cfg.Discord.TokenSource = "none"
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "sync", "--source", "wiretap"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "dm_messages=1")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "sync wiretap"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "sync wiretap import")
|
|
require.Contains(t, out.String(), "@me")
|
|
}
|
|
|
|
func TestParseSyncSources(t *testing.T) {
|
|
for _, tc := range []struct {
|
|
raw string
|
|
name string
|
|
discord bool
|
|
wiretap bool
|
|
}{
|
|
{"", "both", true, true},
|
|
{"both", "both", true, true},
|
|
{"key", "discord", true, false},
|
|
{"discord", "discord", true, false},
|
|
{"wiretap", "wiretap", false, true},
|
|
{"key+wiretap", "both", true, true},
|
|
} {
|
|
sources, err := parseSyncSources(tc.raw)
|
|
require.NoError(t, err)
|
|
require.Equal(t, tc.name, sources.name)
|
|
require.Equal(t, tc.discord, sources.discord)
|
|
require.Equal(t, tc.wiretap, sources.wiretap)
|
|
}
|
|
_, err := parseSyncSources("nope")
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestReadCommandsAutoImportStaleShare(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
sourceDB := filepath.Join(dir, "source.db")
|
|
source := seedCLIStore(t, sourceDB)
|
|
defer func() { _ = source.Close() }()
|
|
|
|
workRepo := filepath.Join(dir, "work")
|
|
remoteRepo := filepath.Join(dir, "remote.git")
|
|
opts := share.Options{RepoPath: workRepo, Branch: "main"}
|
|
_, err := share.Export(ctx, source, opts)
|
|
require.NoError(t, err)
|
|
runGit(t, workRepo, "config", "user.name", "discrawl test")
|
|
runGit(t, workRepo, "config", "user.email", "discrawl@example.com")
|
|
committed, err := share.Commit(ctx, opts, "test: snapshot")
|
|
require.NoError(t, err)
|
|
require.True(t, committed)
|
|
runGit(t, dir, "init", "--bare", remoteRepo)
|
|
runGit(t, workRepo, "remote", "add", "origin", remoteRepo)
|
|
runGit(t, workRepo, "push", "-u", "origin", "main")
|
|
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
cfg := config.Default()
|
|
cfg.DBPath = filepath.Join(dir, "reader.db")
|
|
cfg.Share.Remote = remoteRepo
|
|
cfg.Share.RepoPath = filepath.Join(dir, "reader-share")
|
|
cfg.Share.StaleAfter = "15m"
|
|
cfg.Share.AutoUpdate = true
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "automatic"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "automatic updates work")
|
|
|
|
reader, err := store.Open(ctx, cfg.DBPath)
|
|
require.NoError(t, err)
|
|
defer func() { _ = reader.Close() }()
|
|
lastImport, err := reader.GetSyncState(ctx, share.LastImportSyncScope)
|
|
require.NoError(t, err)
|
|
require.NotEmpty(t, lastImport)
|
|
}
|
|
|
|
func TestReadCommandsCanDisableAutoImportWithEnv(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
sourceDB := filepath.Join(dir, "source.db")
|
|
source := seedCLIStore(t, sourceDB)
|
|
defer func() { _ = source.Close() }()
|
|
|
|
workRepo := filepath.Join(dir, "work")
|
|
remoteRepo := filepath.Join(dir, "remote.git")
|
|
opts := share.Options{RepoPath: workRepo, Branch: "main"}
|
|
_, err := share.Export(ctx, source, opts)
|
|
require.NoError(t, err)
|
|
runGit(t, workRepo, "config", "user.name", "discrawl test")
|
|
runGit(t, workRepo, "config", "user.email", "discrawl@example.com")
|
|
committed, err := share.Commit(ctx, opts, "test: snapshot")
|
|
require.NoError(t, err)
|
|
require.True(t, committed)
|
|
runGit(t, dir, "init", "--bare", remoteRepo)
|
|
runGit(t, workRepo, "remote", "add", "origin", remoteRepo)
|
|
runGit(t, workRepo, "push", "-u", "origin", "main")
|
|
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
cfg := config.Default()
|
|
cfg.DBPath = filepath.Join(dir, "reader.db")
|
|
cfg.Share.Remote = remoteRepo
|
|
cfg.Share.RepoPath = filepath.Join(dir, "reader-share")
|
|
cfg.Share.StaleAfter = "15m"
|
|
cfg.Share.AutoUpdate = true
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
t.Setenv("DISCRAWL_NO_AUTO_UPDATE", "1")
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "automatic"}, &out, &bytes.Buffer{}))
|
|
require.NotContains(t, out.String(), "automatic updates work")
|
|
|
|
reader, err := store.Open(ctx, cfg.DBPath)
|
|
require.NoError(t, err)
|
|
defer func() { _ = reader.Close() }()
|
|
lastImport, err := reader.GetSyncState(ctx, share.LastImportSyncScope)
|
|
require.NoError(t, err)
|
|
require.Empty(t, lastImport)
|
|
}
|
|
|
|
func TestShareCommandsPublishSubscribeAndUpdate(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
remoteRepo := filepath.Join(dir, "remote.git")
|
|
runGit(t, dir, "init", "--bare", remoteRepo)
|
|
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
cfg := config.Default()
|
|
cfg.DBPath = filepath.Join(dir, "publisher.db")
|
|
cfg.Share.Remote = remoteRepo
|
|
cfg.Share.RepoPath = filepath.Join(dir, "publisher-share")
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
publisher := seedCLIStore(t, cfg.DBPath)
|
|
require.NoError(t, publisher.Close())
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{
|
|
"--config", cfgPath,
|
|
"publish",
|
|
"--repo", cfg.Share.RepoPath,
|
|
"--remote", remoteRepo,
|
|
"--readme", filepath.Join(cfg.Share.RepoPath, "README.md"),
|
|
"--no-commit",
|
|
}, &out, &bytes.Buffer{}))
|
|
require.FileExists(t, filepath.Join(cfg.Share.RepoPath, share.ManifestName))
|
|
require.FileExists(t, filepath.Join(cfg.Share.RepoPath, "README.md"))
|
|
|
|
runGit(t, cfg.Share.RepoPath, "config", "user.name", "discrawl test")
|
|
runGit(t, cfg.Share.RepoPath, "config", "user.email", "discrawl@example.com")
|
|
committed, err := share.Commit(ctx, share.Options{RepoPath: cfg.Share.RepoPath, Remote: remoteRepo, Branch: "main"}, "test: snapshot")
|
|
require.NoError(t, err)
|
|
require.True(t, committed)
|
|
require.NoError(t, share.Push(ctx, share.Options{RepoPath: cfg.Share.RepoPath, Remote: remoteRepo, Branch: "main"}))
|
|
|
|
readerCfgPath := filepath.Join(dir, "reader.toml")
|
|
require.NoError(t, Run(ctx, []string{
|
|
"--config", readerCfgPath,
|
|
"subscribe",
|
|
"--repo", filepath.Join(dir, "reader-share"),
|
|
"--no-import",
|
|
remoteRepo,
|
|
}, &bytes.Buffer{}, &bytes.Buffer{}))
|
|
readerCfg, err := config.Load(readerCfgPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, remoteRepo, readerCfg.Share.Remote)
|
|
require.Equal(t, "none", readerCfg.Discord.TokenSource)
|
|
|
|
readerCfg.DBPath = filepath.Join(dir, "reader.db")
|
|
require.NoError(t, config.Write(readerCfgPath, readerCfg))
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", readerCfgPath, "update"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "generated_at")
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", readerCfgPath, "search", "automatic"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "automatic updates work")
|
|
}
|
|
|
|
func TestShareCommandsRoundTripEmbeddings(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
remoteRepo := filepath.Join(dir, "remote.git")
|
|
runGit(t, dir, "init", "--bare", remoteRepo)
|
|
|
|
cfgPath := filepath.Join(dir, "publisher.toml")
|
|
cfg := config.Default()
|
|
cfg.DBPath = filepath.Join(dir, "publisher.db")
|
|
cfg.Share.Remote = remoteRepo
|
|
cfg.Share.RepoPath = filepath.Join(dir, "publisher-share")
|
|
cfg.Search.Embeddings.Enabled = true
|
|
cfg.Search.Embeddings.Provider = "openai_compatible"
|
|
cfg.Search.Embeddings.Model = "local-model"
|
|
cfg.Search.Embeddings.APIKeyEnv = ""
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
publisher := seedCLIStore(t, cfg.DBPath)
|
|
require.NoError(t, insertCLIEmbedding(ctx, publisher, "m100", "openai_compatible", "local-model", []float32{1, 0}))
|
|
require.NoError(t, publisher.Close())
|
|
require.NoError(t, os.MkdirAll(cfg.Share.RepoPath, 0o755))
|
|
runGit(t, cfg.Share.RepoPath, "init")
|
|
runGit(t, cfg.Share.RepoPath, "checkout", "-B", "main")
|
|
runGit(t, cfg.Share.RepoPath, "config", "user.name", "discrawl test")
|
|
runGit(t, cfg.Share.RepoPath, "config", "user.email", "discrawl@example.com")
|
|
runGit(t, cfg.Share.RepoPath, "remote", "add", "origin", remoteRepo)
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{
|
|
"--config", cfgPath,
|
|
"publish",
|
|
"--repo", cfg.Share.RepoPath,
|
|
"--remote", remoteRepo,
|
|
"--with-embeddings",
|
|
"--push",
|
|
}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "embeddings=[")
|
|
|
|
readerCfgPath := filepath.Join(dir, "reader.toml")
|
|
readerCfg := config.Default()
|
|
readerCfg.DBPath = filepath.Join(dir, "reader.db")
|
|
readerCfg.Search.Embeddings.Enabled = true
|
|
readerCfg.Search.Embeddings.Provider = "openai_compatible"
|
|
readerCfg.Search.Embeddings.Model = "local-model"
|
|
readerCfg.Search.Embeddings.APIKeyEnv = ""
|
|
require.NoError(t, config.Write(readerCfgPath, readerCfg))
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{
|
|
"--config", readerCfgPath,
|
|
"subscribe",
|
|
"--repo", filepath.Join(dir, "reader-share"),
|
|
"--with-embeddings",
|
|
remoteRepo,
|
|
}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "imported=true")
|
|
require.Contains(t, out.String(), "embeddings=[")
|
|
|
|
reader, err := store.Open(ctx, readerCfg.DBPath)
|
|
require.NoError(t, err)
|
|
defer func() { _ = reader.Close() }()
|
|
_, rows, err := reader.ReadOnlyQuery(ctx, "select provider, model, count(*) from message_embeddings group by provider, model")
|
|
require.NoError(t, err)
|
|
require.Equal(t, [][]string{{"openai_compatible", "local-model", "1"}}, rows)
|
|
}
|
|
|
|
func TestSubscribeGitOnlyModeNeedsNoDiscordCredentials(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
remoteRepo := filepath.Join(dir, "remote.git")
|
|
runGit(t, dir, "init", "--bare", remoteRepo)
|
|
|
|
publisherDB := filepath.Join(dir, "publisher.db")
|
|
publisher := seedCLIStore(t, publisherDB)
|
|
defer func() { _ = publisher.Close() }()
|
|
publisherRepo := filepath.Join(dir, "publisher-share")
|
|
opts := share.Options{RepoPath: publisherRepo, Remote: remoteRepo, Branch: "main"}
|
|
_, err := share.Export(ctx, publisher, opts)
|
|
require.NoError(t, err)
|
|
runGit(t, publisherRepo, "config", "user.name", "discrawl test")
|
|
runGit(t, publisherRepo, "config", "user.email", "discrawl@example.com")
|
|
committed, err := share.Commit(ctx, opts, "test: snapshot")
|
|
require.NoError(t, err)
|
|
require.True(t, committed)
|
|
require.NoError(t, share.Push(ctx, opts))
|
|
|
|
cfgPath := filepath.Join(dir, "reader.toml")
|
|
readerDB := filepath.Join(dir, "reader.db")
|
|
readerCfg := config.Default()
|
|
readerCfg.DBPath = readerDB
|
|
require.NoError(t, config.Write(cfgPath, readerCfg))
|
|
t.Setenv(config.DefaultTokenEnv, "")
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{
|
|
"--config", cfgPath,
|
|
"subscribe",
|
|
"--repo", filepath.Join(dir, "reader-share"),
|
|
"--stale-after", "1m",
|
|
remoteRepo,
|
|
}, &out, &bytes.Buffer{}))
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "none", cfg.Discord.TokenSource)
|
|
require.True(t, cfg.Share.AutoUpdate)
|
|
require.Equal(t, "1m", cfg.Share.StaleAfter)
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "automatic"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "automatic updates work")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "doctor"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "disabled (git share mode)")
|
|
|
|
err = Run(ctx, []string{"--config", cfgPath, "sync", "--all"}, &out, &bytes.Buffer{})
|
|
require.Equal(t, 4, ExitCode(err))
|
|
require.Contains(t, err.Error(), "discord token disabled")
|
|
}
|
|
|
|
func TestShareUpdateImportsNewRemoteSnapshot(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
remoteRepo := filepath.Join(dir, "remote.git")
|
|
runGit(t, dir, "init", "--bare", remoteRepo)
|
|
|
|
publisherDB := filepath.Join(dir, "publisher.db")
|
|
publisher := seedCLIStore(t, publisherDB)
|
|
defer func() { _ = publisher.Close() }()
|
|
publisherRepo := filepath.Join(dir, "publisher-share")
|
|
opts := share.Options{RepoPath: publisherRepo, Remote: remoteRepo, Branch: "main"}
|
|
publishSnapshot(t, ctx, publisher, opts, "test: old snapshot")
|
|
|
|
readerCfgPath := filepath.Join(dir, "reader.toml")
|
|
readerCfg := config.Default()
|
|
readerCfg.DBPath = filepath.Join(dir, "reader.db")
|
|
readerCfg.Share.Remote = remoteRepo
|
|
readerCfg.Share.RepoPath = filepath.Join(dir, "reader-share")
|
|
readerCfg.Share.AutoUpdate = true
|
|
readerCfg.Share.StaleAfter = "15m"
|
|
readerCfg.Discord.TokenSource = "none"
|
|
require.NoError(t, config.Write(readerCfgPath, readerCfg))
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", readerCfgPath, "update"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "imported=true")
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", readerCfgPath, "update"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "imported=false")
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", readerCfgPath, "search", "automatic"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "automatic updates work")
|
|
|
|
require.NoError(t, publisher.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m200",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "Peter",
|
|
MessageType: 0,
|
|
CreatedAt: time.Now().UTC().Add(time.Minute).Format(time.RFC3339Nano),
|
|
Content: "newer git snapshot arrived",
|
|
NormalizedContent: "newer git snapshot arrived",
|
|
RawJSON: `{}`,
|
|
}))
|
|
publishSnapshot(t, ctx, publisher, opts, "test: new snapshot")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", readerCfgPath, "update"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "imported=true")
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", readerCfgPath, "search", "newer snapshot"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "newer git snapshot arrived")
|
|
}
|
|
|
|
func TestSyncSkipsGitShareByDefaultAndCanImportBeforeLiveDiscord(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
remoteRepo := filepath.Join(dir, "remote.git")
|
|
runGit(t, dir, "init", "--bare", remoteRepo)
|
|
|
|
publisherDB := filepath.Join(dir, "publisher.db")
|
|
publisher := seedCLIStore(t, publisherDB)
|
|
defer func() { _ = publisher.Close() }()
|
|
publisherRepo := filepath.Join(dir, "publisher-share")
|
|
opts := share.Options{RepoPath: publisherRepo, Remote: remoteRepo, Branch: "main"}
|
|
publishSnapshot(t, ctx, publisher, opts, "test: git snapshot")
|
|
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
cfg := config.Default()
|
|
cfg.DBPath = filepath.Join(dir, "reader.db")
|
|
cfg.DefaultGuildID = "g1"
|
|
cfg.Share.Remote = remoteRepo
|
|
cfg.Share.RepoPath = filepath.Join(dir, "reader-share")
|
|
cfg.Share.AutoUpdate = true
|
|
cfg.Share.StaleAfter = "15m"
|
|
cfg.Desktop.Path = filepath.Join(dir, "empty-discord")
|
|
require.NoError(t, os.MkdirAll(cfg.Desktop.Path, 0o755))
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
hybrid := &hybridSyncService{}
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &bytes.Buffer{},
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
openStore: store.Open,
|
|
newDiscord: func(config.Config) (discordClient, error) {
|
|
return &fakeDiscordClient{guilds: []*discordgo.UserGuild{{ID: "g1"}}, self: &discordgo.User{ID: "bot"}}, nil
|
|
},
|
|
newSyncer: func(_ syncer.Client, s *store.Store, _ *slog.Logger) syncService {
|
|
hybrid.store = s
|
|
return hybrid
|
|
},
|
|
}
|
|
|
|
require.NoError(t, rt.dispatch([]string{"sync", "--all"}))
|
|
require.False(t, hybrid.sawGitMessage)
|
|
|
|
reader, err := store.Open(ctx, cfg.DBPath)
|
|
require.NoError(t, err)
|
|
rows, err := reader.ListMessages(ctx, store.MessageListOptions{Channel: "general", IncludeEmpty: true})
|
|
require.NoError(t, err)
|
|
contents := make([]string, 0, len(rows))
|
|
for _, row := range rows {
|
|
contents = append(contents, row.Content)
|
|
}
|
|
require.NotContains(t, contents, "automatic updates work")
|
|
require.Contains(t, contents, "live discord filled the delta")
|
|
require.NoError(t, reader.Close())
|
|
|
|
hybrid.sawGitMessage = false
|
|
require.NoError(t, rt.dispatch([]string{"sync", "--all", "--update=auto"}))
|
|
require.True(t, hybrid.sawGitMessage)
|
|
|
|
reader, err = store.Open(ctx, cfg.DBPath)
|
|
require.NoError(t, err)
|
|
defer func() { _ = reader.Close() }()
|
|
rows, err = reader.ListMessages(ctx, store.MessageListOptions{Channel: "general", IncludeEmpty: true})
|
|
require.NoError(t, err)
|
|
contents = contents[:0]
|
|
for _, row := range rows {
|
|
contents = append(contents, row.Content)
|
|
}
|
|
require.Contains(t, contents, "automatic updates work")
|
|
require.Contains(t, contents, "live discord filled the delta")
|
|
}
|
|
|
|
func TestSyncLockSerializesConcurrentRuns(t *testing.T) {
|
|
if goruntime.GOOS == "windows" {
|
|
t.Skip("sync lock is currently a no-op on Windows")
|
|
}
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfg := config.Default()
|
|
cfg.DBPath = filepath.Join(dir, "discrawl.db")
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
cfg: cfg,
|
|
}
|
|
firstRelease, err := acquireSyncLock(ctx, filepath.Join(dir, ".discrawl-sync.lock"))
|
|
require.NoError(t, err)
|
|
defer func() { _ = firstRelease() }()
|
|
|
|
waitCtx, cancel := context.WithTimeout(ctx, 25*time.Millisecond)
|
|
defer cancel()
|
|
rt.ctx = waitCtx
|
|
err = rt.withSyncLock(func() error { return nil })
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
|
|
waitCtx, cancel = context.WithTimeout(ctx, 25*time.Millisecond)
|
|
defer cancel()
|
|
rt.ctx = waitCtx
|
|
err = rt.dispatch([]string{"update"})
|
|
require.ErrorIs(t, err, context.DeadlineExceeded)
|
|
}
|
|
|
|
func TestReadCommandsDoNotWaitForSyncLock(t *testing.T) {
|
|
if goruntime.GOOS == "windows" {
|
|
t.Skip("sync lock timing is flaky on Windows")
|
|
}
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfg := config.Default()
|
|
cfg.DBPath = filepath.Join(dir, "discrawl.db")
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s := seedCLIStore(t, cfg.DBPath)
|
|
require.NoError(t, s.Close())
|
|
|
|
firstRelease, err := acquireSyncLock(ctx, filepath.Join(dir, ".discrawl-sync.lock"))
|
|
require.NoError(t, err)
|
|
defer func() { _ = firstRelease() }()
|
|
|
|
for _, args := range [][]string{
|
|
{"--config", cfgPath, "search", "automatic"},
|
|
{"--config", cfgPath, "messages", "--channel", "general", "--last", "1"},
|
|
{"--config", cfgPath, "sql", "select count(*) as total from messages"},
|
|
} {
|
|
runCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
|
var out bytes.Buffer
|
|
err := Run(runCtx, args, &out, &bytes.Buffer{})
|
|
cancel()
|
|
require.NoError(t, err, args)
|
|
require.NotEmpty(t, out.String(), args)
|
|
}
|
|
}
|
|
|
|
func TestReadCommandsMigrateOlderLocalStore(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfg := config.Default()
|
|
cfg.DBPath = filepath.Join(dir, "discrawl.db")
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s := seedCLIStore(t, cfg.DBPath)
|
|
_, err := s.DB().ExecContext(ctx, `pragma user_version = 1`)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.Close())
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "automatic"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "automatic updates work")
|
|
|
|
reader, err := store.OpenReadOnly(ctx, cfg.DBPath)
|
|
require.NoError(t, err)
|
|
defer func() { _ = reader.Close() }()
|
|
var version int
|
|
require.NoError(t, reader.DB().QueryRowContext(ctx, `pragma user_version`).Scan(&version))
|
|
require.Equal(t, 2, version)
|
|
}
|
|
|
|
func seedCLIStore(t *testing.T, path string) *store.Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
s, err := store.Open(ctx, path)
|
|
require.NoError(t, err)
|
|
now := time.Now().UTC()
|
|
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m100",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "Peter",
|
|
MessageType: 0,
|
|
CreatedAt: now.Format(time.RFC3339Nano),
|
|
Content: "automatic updates work",
|
|
NormalizedContent: "automatic updates work",
|
|
RawJSON: `{}`,
|
|
}))
|
|
return s
|
|
}
|
|
|
|
func publishSnapshot(t *testing.T, ctx context.Context, s *store.Store, opts share.Options, message string) {
|
|
t.Helper()
|
|
_, err := share.Export(ctx, s, opts)
|
|
require.NoError(t, err)
|
|
runGit(t, opts.RepoPath, "config", "user.name", "discrawl test")
|
|
runGit(t, opts.RepoPath, "config", "user.email", "discrawl@example.com")
|
|
committed, err := share.Commit(ctx, opts, message)
|
|
require.NoError(t, err)
|
|
require.True(t, committed)
|
|
require.NoError(t, share.Push(ctx, opts))
|
|
}
|
|
|
|
func runGit(t *testing.T, dir string, args ...string) {
|
|
t.Helper()
|
|
// #nosec G204 -- fixed git argv in test setup.
|
|
cmd := exec.CommandContext(t.Context(), "git", args...)
|
|
cmd.Dir = dir
|
|
out, err := cmd.CombinedOutput()
|
|
require.NoError(t, err, string(out))
|
|
}
|
|
|
|
func TestEmbedCommandDrainsBoundedBacklog(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/embeddings", r.URL.Path)
|
|
var req struct {
|
|
Input []string `json:"input"`
|
|
}
|
|
assert.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
|
assert.Len(t, req.Input, 1)
|
|
_, _ = w.Write([]byte(`{"data":[{"index":0,"embedding":[1,2]}]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Search.Embeddings.Enabled = true
|
|
cfg.Search.Embeddings.Provider = "openai_compatible"
|
|
cfg.Search.Embeddings.Model = "local-model"
|
|
cfg.Search.Embeddings.BaseURL = server.URL
|
|
cfg.Search.Embeddings.APIKeyEnv = ""
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
for _, id := range []string{"m1", "m2"} {
|
|
require.NoError(t, s.UpsertMessageWithOptions(ctx, store.MessageRecord{
|
|
ID: id,
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
MessageType: 0,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
Content: "hello",
|
|
NormalizedContent: "hello",
|
|
RawJSON: `{}`,
|
|
}, store.WriteOptions{EnqueueEmbedding: true}))
|
|
}
|
|
require.NoError(t, s.Close())
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "embed", "--limit", "1"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "processed=1")
|
|
require.Contains(t, out.String(), "succeeded=1")
|
|
require.Contains(t, out.String(), "remaining_backlog=1")
|
|
require.Contains(t, out.String(), "provider=openai_compatible")
|
|
|
|
s, err = store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
_, rows, err := s.ReadOnlyQuery(ctx, "select count(*) from message_embeddings")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "1", rows[0][0])
|
|
require.NoError(t, s.Close())
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "embed", "--rebuild", "--limit", "1"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "processed=1")
|
|
require.Contains(t, out.String(), "succeeded=1")
|
|
require.Contains(t, out.String(), "remaining_backlog=1")
|
|
require.Contains(t, out.String(), "requeued=2")
|
|
}
|
|
|
|
func TestSearchSemanticCommandUsesStoredEmbeddings(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
var requests int
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
requests++
|
|
assert.Equal(t, "/embeddings", r.URL.Path)
|
|
var req struct {
|
|
Model string `json:"model"`
|
|
Input []string `json:"input"`
|
|
}
|
|
assert.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
|
assert.Equal(t, "local-model", req.Model)
|
|
assert.Equal(t, []string{"cats"}, req.Input)
|
|
_, _ = w.Write([]byte(`{"model":"local-model","data":[{"index":0,"embedding":[1,0]}]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Search.DefaultMode = "semantic"
|
|
cfg.Search.Embeddings.Enabled = true
|
|
cfg.Search.Embeddings.Provider = "openai_compatible"
|
|
cfg.Search.Embeddings.Model = "local-model"
|
|
cfg.Search.Embeddings.BaseURL = server.URL
|
|
cfg.Search.Embeddings.APIKeyEnv = ""
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
base := time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC)
|
|
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m1",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "Alice",
|
|
MessageType: 0,
|
|
CreatedAt: base.Format(time.RFC3339Nano),
|
|
Content: "database migration discussion",
|
|
NormalizedContent: "database migration discussion",
|
|
RawJSON: `{"author":{"username":"Alice"}}`,
|
|
}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m2",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u2",
|
|
AuthorName: "Bob",
|
|
MessageType: 0,
|
|
CreatedAt: base.Add(time.Minute).Format(time.RFC3339Nano),
|
|
Content: "cats in semantic search",
|
|
NormalizedContent: "cats in semantic search",
|
|
RawJSON: `{"author":{"username":"Bob"}}`,
|
|
}))
|
|
require.NoError(t, insertCLIEmbedding(ctx, s, "m1", "openai_compatible", "local-model", []float32{1, 0}))
|
|
require.NoError(t, insertCLIEmbedding(ctx, s, "m2", "openai_compatible", "local-model", []float32{0.8, 0.2}))
|
|
require.NoError(t, s.Close())
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "--limit", "1", "cats"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "database migration discussion")
|
|
require.NotContains(t, out.String(), "cats in semantic search")
|
|
require.Equal(t, 1, requests)
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "--mode", "semantic", "--channel", "general", "--author", "Alice", "cats"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "database migration discussion")
|
|
require.NotContains(t, out.String(), "cats in semantic search")
|
|
require.Equal(t, 2, requests)
|
|
}
|
|
|
|
func TestSearchHybridCommandFusesResults(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/embeddings", r.URL.Path)
|
|
var req struct {
|
|
Model string `json:"model"`
|
|
Input []string `json:"input"`
|
|
}
|
|
assert.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
|
assert.Equal(t, "local-model", req.Model)
|
|
assert.Equal(t, []string{"panic"}, req.Input)
|
|
_, _ = w.Write([]byte(`{"model":"local-model","data":[{"index":0,"embedding":[1,0]}]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Search.DefaultMode = "hybrid"
|
|
cfg.Search.Embeddings.Enabled = true
|
|
cfg.Search.Embeddings.Provider = "openai_compatible"
|
|
cfg.Search.Embeddings.Model = "local-model"
|
|
cfg.Search.Embeddings.BaseURL = server.URL
|
|
cfg.Search.Embeddings.APIKeyEnv = ""
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
base := time.Date(2026, 4, 22, 12, 0, 0, 0, time.UTC)
|
|
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m3",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "Alice",
|
|
MessageType: 0,
|
|
CreatedAt: base.Format(time.RFC3339Nano),
|
|
Content: "panic stack trace",
|
|
NormalizedContent: "panic stack trace",
|
|
RawJSON: `{"author":{"username":"Alice"}}`,
|
|
}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m2",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u2",
|
|
AuthorName: "Bob",
|
|
MessageType: 0,
|
|
CreatedAt: base.Add(time.Minute).Format(time.RFC3339Nano),
|
|
Content: "database worker stalled",
|
|
NormalizedContent: "database worker stalled",
|
|
RawJSON: `{"author":{"username":"Bob"}}`,
|
|
}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m1",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u3",
|
|
AuthorName: "Carol",
|
|
MessageType: 0,
|
|
CreatedAt: base.Add(2 * time.Minute).Format(time.RFC3339Nano),
|
|
Content: "panic database lock",
|
|
NormalizedContent: "panic database lock",
|
|
RawJSON: `{"author":{"username":"Carol"}}`,
|
|
}))
|
|
require.NoError(t, insertCLIEmbedding(ctx, s, "m1", "openai_compatible", "local-model", []float32{0.9, 0.1}))
|
|
require.NoError(t, insertCLIEmbedding(ctx, s, "m2", "openai_compatible", "local-model", []float32{1, 0}))
|
|
require.NoError(t, insertCLIEmbedding(ctx, s, "m3", "openai_compatible", "local-model", []float32{0, 1}))
|
|
require.NoError(t, s.Close())
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "--limit", "3", "panic"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "panic database lock")
|
|
require.Contains(t, out.String(), "database worker stalled")
|
|
require.Contains(t, out.String(), "panic stack trace")
|
|
}
|
|
|
|
func TestSearchSemanticCommandErrors(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.Close())
|
|
|
|
err = Run(ctx, []string{"--config", cfgPath, "search", "--mode", "bogus", "cats"}, &bytes.Buffer{}, &bytes.Buffer{})
|
|
require.Equal(t, 2, ExitCode(err))
|
|
require.ErrorContains(t, err, `unsupported search mode "bogus"`)
|
|
|
|
err = Run(ctx, []string{"--config", cfgPath, "search", "--mode", "semantic", "cats"}, &bytes.Buffer{}, &bytes.Buffer{})
|
|
require.Equal(t, 1, ExitCode(err))
|
|
require.ErrorContains(t, err, "embeddings are disabled")
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
http.Error(w, "nope", http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
cfg.Search.Embeddings.Enabled = true
|
|
cfg.Search.Embeddings.Provider = "openai_compatible"
|
|
cfg.Search.Embeddings.Model = "local-model"
|
|
cfg.Search.Embeddings.BaseURL = server.URL
|
|
cfg.Search.Embeddings.APIKeyEnv = ""
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
err = Run(ctx, []string{"--config", cfgPath, "search", "--mode", "semantic", "cats"}, &bytes.Buffer{}, &bytes.Buffer{})
|
|
require.Equal(t, 1, ExitCode(err))
|
|
require.ErrorContains(t, err, "embedding query failed")
|
|
}
|
|
|
|
func TestSearchHybridCommandFallsBackToFTS(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m1",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "Alice",
|
|
MessageType: 0,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
Content: "panic exact match",
|
|
NormalizedContent: "panic exact match",
|
|
RawJSON: `{"author":{"username":"Alice"}}`,
|
|
}))
|
|
require.NoError(t, s.Close())
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "--mode", "hybrid", "panic"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "panic exact match")
|
|
|
|
okRequests := 0
|
|
okServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
okRequests++
|
|
_, _ = w.Write([]byte(`{"model":"local-model","data":[{"index":0,"embedding":[1,0]}]}`))
|
|
}))
|
|
defer okServer.Close()
|
|
cfg.Search.Embeddings.Enabled = true
|
|
cfg.Search.Embeddings.Provider = "openai_compatible"
|
|
cfg.Search.Embeddings.Model = "local-model"
|
|
cfg.Search.Embeddings.BaseURL = okServer.URL
|
|
cfg.Search.Embeddings.APIKeyEnv = ""
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "--mode", "hybrid", "panic"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "panic exact match")
|
|
require.Equal(t, 0, okRequests)
|
|
|
|
s, err = store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, insertCLIEmbedding(ctx, s, "m1", "openai_compatible", "local-model", []float32{1, 0}))
|
|
require.NoError(t, s.Close())
|
|
|
|
failedRequests := 0
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
failedRequests++
|
|
http.Error(w, "nope", http.StatusInternalServerError)
|
|
}))
|
|
defer server.Close()
|
|
cfg.Search.Embeddings.Enabled = true
|
|
cfg.Search.Embeddings.Provider = "openai_compatible"
|
|
cfg.Search.Embeddings.Model = "local-model"
|
|
cfg.Search.Embeddings.BaseURL = server.URL
|
|
cfg.Search.Embeddings.APIKeyEnv = ""
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "search", "--mode", "hybrid", "panic"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "panic exact match")
|
|
require.Equal(t, 1, failedRequests)
|
|
}
|
|
|
|
func insertCLIEmbedding(ctx context.Context, s *store.Store, messageID, provider, model string, vector []float32) error {
|
|
blob, err := store.EncodeEmbeddingVector(vector)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
_, err = s.DB().ExecContext(ctx, `
|
|
insert into message_embeddings(
|
|
message_id, provider, model, input_version, dimensions, embedding_blob, embedded_at
|
|
) values(?, ?, ?, ?, ?, ?, ?)
|
|
`, messageID, provider, model, store.EmbeddingInputVersion, len(vector), blob, time.Now().UTC().Format(time.RFC3339Nano))
|
|
return err
|
|
}
|
|
|
|
type fakeDiscordClient struct {
|
|
guilds []*discordgo.UserGuild
|
|
self *discordgo.User
|
|
}
|
|
|
|
func (f *fakeDiscordClient) Close() error { return nil }
|
|
func (f *fakeDiscordClient) Self(context.Context) (*discordgo.User, error) {
|
|
return f.self, nil
|
|
}
|
|
|
|
func (f *fakeDiscordClient) Guilds(context.Context) ([]*discordgo.UserGuild, error) {
|
|
return f.guilds, nil
|
|
}
|
|
|
|
func (f *fakeDiscordClient) Guild(context.Context, string) (*discordgo.Guild, error) {
|
|
return &discordgo.Guild{}, nil
|
|
}
|
|
|
|
func (f *fakeDiscordClient) GuildChannels(context.Context, string) ([]*discordgo.Channel, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeDiscordClient) ThreadsActive(context.Context, string) ([]*discordgo.Channel, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeDiscordClient) GuildThreadsActive(context.Context, string) ([]*discordgo.Channel, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeDiscordClient) ThreadsArchived(context.Context, string, bool) ([]*discordgo.Channel, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeDiscordClient) GuildMembers(context.Context, string) ([]*discordgo.Member, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeDiscordClient) ChannelMessages(context.Context, string, int, string, string) ([]*discordgo.Message, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeDiscordClient) ChannelMessage(context.Context, string, string) (*discordgo.Message, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func (f *fakeDiscordClient) Tail(context.Context, discordclient.EventHandler) error {
|
|
return nil
|
|
}
|
|
|
|
type fakeSyncService struct {
|
|
discovered []*discordgo.UserGuild
|
|
lastSync syncer.SyncOptions
|
|
lastTail []string
|
|
lastRepair time.Duration
|
|
attachmentTextEnabled bool
|
|
}
|
|
|
|
func (f *fakeSyncService) DiscoverGuilds(context.Context) ([]*discordgo.UserGuild, error) {
|
|
return f.discovered, nil
|
|
}
|
|
|
|
func (f *fakeSyncService) Sync(_ context.Context, opts syncer.SyncOptions) (syncer.SyncStats, error) {
|
|
f.lastSync = opts
|
|
return syncer.SyncStats{Guilds: len(opts.GuildIDs), Messages: 3}, nil
|
|
}
|
|
|
|
func (f *fakeSyncService) RunTail(_ context.Context, guildIDs []string, repairEvery time.Duration) error {
|
|
f.lastTail = guildIDs
|
|
f.lastRepair = repairEvery
|
|
return nil
|
|
}
|
|
|
|
func (f *fakeSyncService) SetAttachmentTextEnabled(enabled bool) {
|
|
f.attachmentTextEnabled = enabled
|
|
}
|
|
|
|
type hybridSyncService struct {
|
|
store *store.Store
|
|
sawGitMessage bool
|
|
}
|
|
|
|
func (f *hybridSyncService) DiscoverGuilds(context.Context) ([]*discordgo.UserGuild, error) {
|
|
return []*discordgo.UserGuild{{ID: "g1"}}, nil
|
|
}
|
|
|
|
func (f *hybridSyncService) Sync(ctx context.Context, opts syncer.SyncOptions) (syncer.SyncStats, error) {
|
|
rows, err := f.store.ListMessages(ctx, store.MessageListOptions{Channel: "general", IncludeEmpty: true})
|
|
if err != nil {
|
|
return syncer.SyncStats{}, err
|
|
}
|
|
for _, row := range rows {
|
|
if row.Content == "automatic updates work" {
|
|
f.sawGitMessage = true
|
|
break
|
|
}
|
|
}
|
|
if err := f.store.UpsertGuild(ctx, store.GuildRecord{ID: "g1", Name: "Guild", RawJSON: `{}`}); err != nil {
|
|
return syncer.SyncStats{}, err
|
|
}
|
|
if err := f.store.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}); err != nil {
|
|
return syncer.SyncStats{}, err
|
|
}
|
|
if err := f.store.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m-live",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "Peter",
|
|
MessageType: 0,
|
|
CreatedAt: time.Now().UTC().Add(time.Minute).Format(time.RFC3339Nano),
|
|
Content: "live discord filled the delta",
|
|
NormalizedContent: "live discord filled the delta",
|
|
RawJSON: `{}`,
|
|
}); err != nil {
|
|
return syncer.SyncStats{}, err
|
|
}
|
|
return syncer.SyncStats{Guilds: len(opts.GuildIDs), Messages: 1}, nil
|
|
}
|
|
|
|
func (f *hybridSyncService) RunTail(context.Context, []string, time.Duration) error {
|
|
return nil
|
|
}
|
|
|
|
func TestRuntimeInitSyncTailAndDoctor(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
t.Setenv(config.DefaultTokenEnv, "env-token")
|
|
|
|
fakeDiscord := &fakeDiscordClient{
|
|
guilds: []*discordgo.UserGuild{{ID: "g1"}, {ID: "g2"}},
|
|
self: &discordgo.User{ID: "bot"},
|
|
}
|
|
fakeSync := &fakeSyncService{discovered: fakeDiscord.guilds}
|
|
|
|
newRuntime := func() *runtime {
|
|
return &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &bytes.Buffer{},
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
openStore: store.Open,
|
|
newDiscord: func(config.Config) (discordClient, error) { return fakeDiscord, nil },
|
|
newSyncer: func(syncer.Client, *store.Store, *slog.Logger) syncService {
|
|
return fakeSync
|
|
},
|
|
}
|
|
}
|
|
|
|
rt := newRuntime()
|
|
require.NoError(t, rt.runInit([]string{"--db", dbPath, "--with-embeddings", "--guild", "g2"}))
|
|
|
|
cfg, err := config.Load(cfgPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, []string{"g1", "g2"}, cfg.GuildIDs)
|
|
require.Equal(t, "g2", cfg.DefaultGuildID)
|
|
require.True(t, cfg.Search.Embeddings.Enabled)
|
|
cfg.Desktop.Path = filepath.Join(dir, "empty-discord")
|
|
require.NoError(t, os.MkdirAll(cfg.Desktop.Path, 0o755))
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
rt = newRuntime()
|
|
require.NoError(t, rt.withServices(true, func() error { return rt.runSync([]string{"--guilds", "g2"}) }))
|
|
require.Equal(t, []string{"g2"}, fakeSync.lastSync.GuildIDs)
|
|
require.True(t, fakeSync.lastSync.LatestOnly)
|
|
require.True(t, fakeSync.lastSync.SkipMembers)
|
|
require.True(t, fakeSync.attachmentTextEnabled)
|
|
|
|
rt = newRuntime()
|
|
require.NoError(t, rt.withServices(true, func() error { return rt.runSync([]string{"--all"}) }))
|
|
require.Nil(t, fakeSync.lastSync.GuildIDs)
|
|
require.True(t, fakeSync.lastSync.LatestOnly)
|
|
require.True(t, fakeSync.lastSync.SkipMembers)
|
|
|
|
rt = newRuntime()
|
|
require.NoError(t, rt.withServices(true, func() error { return rt.runSync([]string{"--guilds", "g2", "--all-channels"}) }))
|
|
require.Equal(t, []string{"g2"}, fakeSync.lastSync.GuildIDs)
|
|
require.False(t, fakeSync.lastSync.LatestOnly)
|
|
require.False(t, fakeSync.lastSync.SkipMembers)
|
|
|
|
rt = newRuntime()
|
|
require.NoError(t, rt.withServices(true, func() error { return rt.runTail([]string{"--repair-every", "30s"}) }))
|
|
require.Equal(t, []string{"g2"}, fakeSync.lastTail)
|
|
require.Equal(t, 30*time.Second, fakeSync.lastRepair)
|
|
|
|
rt = newRuntime()
|
|
var out bytes.Buffer
|
|
rt.stdout = &out
|
|
require.NoError(t, rt.runDoctor(nil))
|
|
require.Contains(t, out.String(), "discord_auth=ok")
|
|
}
|
|
|
|
func TestSyncModeDefaults(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
tests := []struct {
|
|
name string
|
|
full bool
|
|
allChannels bool
|
|
since string
|
|
channels string
|
|
defaultLatest bool
|
|
latestOnly bool
|
|
skipMembers bool
|
|
explicitLatest bool
|
|
explicitSkip bool
|
|
}{
|
|
{name: "routine", defaultLatest: true, latestOnly: true, skipMembers: true},
|
|
{name: "all channels", allChannels: true},
|
|
{name: "full", full: true},
|
|
{name: "since", since: "2026-04-27T20:00:00Z"},
|
|
{name: "channels", channels: "c1"},
|
|
{name: "explicit latest", allChannels: true, explicitLatest: true, latestOnly: true},
|
|
{name: "explicit skip members", allChannels: true, explicitSkip: true, skipMembers: true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
defaultLatest := defaultLatestSyncMode(tt.full, tt.allChannels, tt.since, tt.channels)
|
|
require.Equal(t, tt.defaultLatest, defaultLatest)
|
|
require.Equal(t, tt.latestOnly, syncLatestOnly(tt.explicitLatest, defaultLatest))
|
|
require.Equal(t, tt.skipMembers, syncSkipsMembers(tt.explicitSkip, defaultLatest))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDoctorChecksEnabledLocalEmbeddingProvider(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
assert.Equal(t, "/api/embed", r.URL.Path)
|
|
_, _ = w.Write([]byte(`{"model":"nomic-embed-text","embeddings":[[1,2,3]]}`))
|
|
}))
|
|
defer server.Close()
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Search.Embeddings.Enabled = true
|
|
cfg.Search.Embeddings.Provider = "ollama"
|
|
cfg.Search.Embeddings.Model = "nomic-embed-text"
|
|
cfg.Search.Embeddings.BaseURL = server.URL
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
var out bytes.Buffer
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &out,
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
}
|
|
require.NoError(t, rt.runDoctor(nil))
|
|
require.Contains(t, out.String(), "embeddings=ok")
|
|
require.Contains(t, out.String(), "embeddings_provider=ollama")
|
|
require.Contains(t, out.String(), "embeddings_probe=ok")
|
|
}
|
|
|
|
func TestDoctorReportsEmbeddingProviderWarningsNonFatally(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
t.Setenv("OPENAI_API_KEY", "")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Search.Embeddings.Enabled = true
|
|
cfg.Search.Embeddings.Provider = "openai"
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
var out bytes.Buffer
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &out,
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
}
|
|
require.NoError(t, rt.runDoctor(nil))
|
|
require.Contains(t, out.String(), "embeddings=warning")
|
|
require.Contains(t, out.String(), "embeddings_warning=embedding provider \"openai\" requires API key env OPENAI_API_KEY")
|
|
}
|
|
|
|
func TestDoctorReportsUnsupportedEmbeddingProviderNonFatally(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Search.Embeddings.Enabled = true
|
|
cfg.Search.Embeddings.Provider = "bogus"
|
|
cfg.Search.Embeddings.Model = "custom"
|
|
cfg.Search.Embeddings.APIKeyEnv = ""
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
var out bytes.Buffer
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &out,
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
}
|
|
require.NoError(t, rt.runDoctor(nil))
|
|
require.Contains(t, out.String(), "embeddings=warning")
|
|
require.Contains(t, out.String(), "embeddings_warning=unsupported embedding provider \"bogus\"")
|
|
}
|
|
|
|
func TestRuntimeConfiguresAttachmentTextOnSyncer(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
t.Setenv(config.DefaultTokenEnv, "env-token")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.Sync.AttachmentText = nil
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
fakeSync := &fakeSyncService{}
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &bytes.Buffer{},
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
openStore: store.Open,
|
|
newDiscord: func(config.Config) (discordClient, error) { return &fakeDiscordClient{}, nil },
|
|
newSyncer: func(syncer.Client, *store.Store, *slog.Logger) syncService {
|
|
return fakeSync
|
|
},
|
|
}
|
|
require.NoError(t, rt.withServices(true, func() error { return nil }))
|
|
require.True(t, fakeSync.attachmentTextEnabled)
|
|
|
|
cfg.Sync.AttachmentText = new(false)
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
require.NoError(t, rt.withServices(true, func() error { return nil }))
|
|
require.False(t, fakeSync.attachmentTextEnabled)
|
|
}
|
|
|
|
func TestSQLRejectsMutationsByDefaultAndAllowsUnsafeConfirm(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m1",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
MessageType: 0,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
Content: "hello",
|
|
NormalizedContent: "hello",
|
|
RawJSON: `{}`,
|
|
}))
|
|
require.NoError(t, s.Close())
|
|
|
|
err = Run(ctx, []string{"--config", cfgPath, "sql", "delete from messages"}, &bytes.Buffer{}, &bytes.Buffer{})
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "only read-only sql is allowed")
|
|
|
|
var out bytes.Buffer
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "sql", "--unsafe", "--confirm", "select count(*) as total from messages"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "total")
|
|
|
|
out.Reset()
|
|
require.NoError(t, Run(ctx, []string{"--config", cfgPath, "sql", "--unsafe", "--confirm", "delete from messages"}, &out, &bytes.Buffer{}))
|
|
require.Contains(t, out.String(), "rows_affected=1")
|
|
|
|
s, err = store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
defer func() { _ = s.Close() }()
|
|
_, rows, err := s.ReadOnlyQuery(ctx, "select count(*) from messages")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "0", rows[0][0])
|
|
}
|
|
|
|
func TestCommandUsageBranches(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.Close())
|
|
|
|
cases := []struct {
|
|
args []string
|
|
want string
|
|
}{
|
|
{[]string{"--config", cfgPath, "sql", "--confirm", "select 1"}, "--confirm requires --unsafe"},
|
|
{[]string{"--config", cfgPath, "sql", "--unsafe", "delete from messages"}, "--unsafe requires --confirm"},
|
|
{[]string{"--config", cfgPath, "search"}, "search requires a query"},
|
|
{[]string{"--config", cfgPath, "search", "--dm", "--guild", "g1", "panic"}, "use either --dm or --guild/--guilds"},
|
|
{[]string{"--config", cfgPath, "messages", "--dm", "--guild", "g1"}, "use either --dm or --guild/--guilds"},
|
|
{[]string{"--config", cfgPath, "messages", "--dm", "--sync"}, "messages --sync is not supported with --dm"},
|
|
{[]string{"--config", cfgPath, "dms", "extra"}, "dms takes flags only"},
|
|
{[]string{"--config", cfgPath, "wiretap", "extra"}, "wiretap takes flags only"},
|
|
{[]string{"--config", cfgPath, "wiretap", "--max-file-bytes", "0"}, "--max-file-bytes must be positive"},
|
|
{[]string{"--config", cfgPath, "wiretap", "--watch-every", "1ms"}, "--watch-every must be at least 1s"},
|
|
{[]string{"--config", cfgPath, "members"}, "members requires a subcommand"},
|
|
{[]string{"--config", cfgPath, "members", "search"}, "members search requires a query"},
|
|
{[]string{"--config", cfgPath, "members", "bogus"}, `unknown members subcommand "bogus"`},
|
|
{[]string{"--config", cfgPath, "channels"}, "channels requires a subcommand"},
|
|
{[]string{"--config", cfgPath, "channels", "bogus"}, `unknown channels subcommand "bogus"`},
|
|
{[]string{"--config", cfgPath, "status", "extra"}, "status takes no arguments"},
|
|
{[]string{"--config", cfgPath, "report", "extra"}, "report takes no positional arguments"},
|
|
{[]string{"--config", cfgPath, "embed"}, "embeddings are disabled"},
|
|
{[]string{"--config", cfgPath, "embed", "--limit", "0"}, "--limit must be positive"},
|
|
{[]string{"--config", cfgPath, "embed", "--batch-size", "0"}, "--batch-size must be positive"},
|
|
{[]string{"--config", cfgPath, "publish", "extra"}, "publish takes no positional arguments"},
|
|
{[]string{"--config", cfgPath, "update", "extra"}, "update takes no positional arguments"},
|
|
{[]string{"--config", cfgPath, "subscribe"}, "subscribe requires one remote"},
|
|
{[]string{"--config", cfgPath, "subscribe", "one", "two"}, "subscribe requires one remote"},
|
|
}
|
|
for _, tc := range cases {
|
|
err := Run(ctx, tc.args, &bytes.Buffer{}, &bytes.Buffer{})
|
|
require.Equal(t, 2, ExitCode(err), tc.args)
|
|
require.ErrorContains(t, err, tc.want, tc.args)
|
|
}
|
|
}
|
|
|
|
func TestHelpers(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
require.Equal(t, []string{"a", "b"}, csvList("a,b,a"))
|
|
require.Equal(t, "x", (&cliError{code: 2, err: assertErr("x")}).Error())
|
|
mode, err := syncShareUpdateMode([]string{"--all"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, shareUpdateNever, mode)
|
|
mode, err = syncShareUpdateMode([]string{"--update=auto"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, shareUpdateAuto, mode)
|
|
mode, err = syncShareUpdateMode([]string{"--update", "force"})
|
|
require.NoError(t, err)
|
|
require.Equal(t, shareUpdateForce, mode)
|
|
_, err = syncShareUpdateMode([]string{"--update"})
|
|
require.Error(t, err)
|
|
require.Equal(t, 2, ExitCode(usageErr(assertErr("x"))))
|
|
require.Equal(t, 4, ExitCode(authErr(assertErr("x"))))
|
|
require.Equal(t, 5, ExitCode(dbErr(assertErr("x"))))
|
|
require.Equal(t, 3, ExitCode(configErr(assertErr("x"))))
|
|
require.Equal(t, 1, ExitCode(assertErr("x")))
|
|
require.True(t, hybridSemanticUnavailable(store.ErrNoCompatibleEmbeddings))
|
|
require.True(t, hybridSemanticUnavailable(assertErr("semantic query embedding missing")))
|
|
require.False(t, hybridSemanticUnavailable(assertErr("other")))
|
|
opts, err := shareOptionsFromFlags("~/share", "git@example.com:org/archive.git", "")
|
|
require.NoError(t, err)
|
|
require.Equal(t, "git@example.com:org/archive.git", opts.Remote)
|
|
require.Equal(t, "main", opts.Branch)
|
|
var out bytes.Buffer
|
|
require.NoError(t, printHuman(&out, syncer.SyncStats{Guilds: 1}))
|
|
require.Contains(t, out.String(), "guilds=1")
|
|
require.Contains(t, formatTime(time.Unix(1, 0).UTC()), "1970")
|
|
require.Equal(t, "x", firstNonEmpty("", "x", "y"))
|
|
}
|
|
|
|
type assertErr string
|
|
|
|
func (e assertErr) Error() string { return string(e) }
|
|
|
|
func discardLogger() *slog.Logger {
|
|
return slog.New(slog.DiscardHandler)
|
|
}
|
|
|
|
func TestRuntimeHelpersAndSubcommands(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.DefaultGuildID = "g1"
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "dm1", GuildID: store.DirectMessageGuildID, Kind: "dm", Name: "Alice", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u1", Username: "peter", RoleIDsJSON: `[]`, RawJSON: `{}`}))
|
|
base := time.Date(2026, 3, 8, 10, 0, 0, 0, time.UTC)
|
|
require.NoError(t, s.UpsertMessages(ctx, []store.MessageMutation{
|
|
{
|
|
Record: store.MessageRecord{
|
|
ID: "m1",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "peter",
|
|
CreatedAt: base.Format(time.RFC3339Nano),
|
|
Content: "hello <@u1> in <#c1>",
|
|
NormalizedContent: "hello <@u1> in <#c1>",
|
|
RawJSON: `{"author":{"username":"peter"}}`,
|
|
},
|
|
Mentions: []store.MentionEventRecord{{
|
|
MessageID: "m1",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
AuthorID: "u1",
|
|
TargetType: "user",
|
|
TargetID: "u1",
|
|
TargetName: "peter",
|
|
EventAt: base.Format(time.RFC3339Nano),
|
|
}},
|
|
},
|
|
{
|
|
Record: store.MessageRecord{
|
|
ID: "dm-msg",
|
|
GuildID: store.DirectMessageGuildID,
|
|
ChannelID: "dm1",
|
|
ChannelName: "Alice",
|
|
AuthorID: "u2",
|
|
AuthorName: "Alice",
|
|
CreatedAt: base.Add(time.Minute).Format(time.RFC3339Nano),
|
|
Content: "private hello",
|
|
NormalizedContent: "private hello",
|
|
RawJSON: `{"source":"discord_desktop"}`,
|
|
},
|
|
},
|
|
}))
|
|
require.NoError(t, s.Close())
|
|
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &bytes.Buffer{},
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
}
|
|
require.NoError(t, rt.withServices(false, func() error {
|
|
require.Equal(t, []string{"g1"}, rt.resolveSyncGuilds("", ""))
|
|
require.Nil(t, rt.resolveSearchGuilds("", ""))
|
|
require.NoError(t, rt.runMembers([]string{"show", "u1"}))
|
|
require.NoError(t, rt.runMembers([]string{"search", "pet"}))
|
|
require.NoError(t, rt.runMembers([]string{"list"}))
|
|
rt.now = func() time.Time { return time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) }
|
|
require.NoError(t, rt.runMessages([]string{"--channel", "#general", "--hours", "6", "--last", "1"}))
|
|
require.NoError(t, rt.runMessages([]string{"--channel", "#general", "--days", "7", "--all"}))
|
|
require.NoError(t, rt.runMessages([]string{"--channel", "#general", "--days", "7", "--all", "--include-empty"}))
|
|
require.NoError(t, rt.runMessages([]string{"--channel", "#general", "--since", "2026-03-08T00:00:00Z", "--before", "2026-03-09T00:00:00Z", "--limit", "1"}))
|
|
require.NoError(t, rt.runMessages([]string{"--dm", "--channel", "Alice", "--last", "1"}))
|
|
require.NoError(t, rt.runDirectMessages([]string{"--list"}))
|
|
require.NoError(t, rt.runDirectMessages([]string{"--with", "Alice", "--search", "private", "--limit", "1"}))
|
|
require.NoError(t, rt.runDirectMessages([]string{"--with", "Alice", "--since", "2026-03-08T00:00:00Z", "--before", "2026-03-09T00:00:00Z", "--all"}))
|
|
require.NoError(t, rt.runMentions([]string{"--channel", "#general", "--target", "u2"}))
|
|
require.NoError(t, rt.runMentions([]string{"--channel", "#general", "--days", "7", "--type", "user"}))
|
|
require.NoError(t, rt.runDigest([]string{"--since", "12h", "--channel", "general", "--top-n", "2"}))
|
|
require.NoError(t, rt.runReport([]string{"--readme", filepath.Join(dir, "README.md")}))
|
|
require.NoError(t, rt.runSearch([]string{"--include-empty", "Peter"}))
|
|
require.NoError(t, rt.runChannels([]string{"show", "c1"}))
|
|
require.NoError(t, rt.runChannels([]string{"list"}))
|
|
require.NoError(t, rt.runStatus(nil))
|
|
require.NoError(t, rt.runAnalytics([]string{}))
|
|
require.NoError(t, rt.runTUI([]string{"--json", "--limit", "1", "--include-empty"}))
|
|
require.NoError(t, rt.runAnalytics([]string{"quiet", "--since", "1d"}))
|
|
require.NoError(t, rt.runAnalytics([]string{"trends", "--weeks", "1", "--channel", "general"}))
|
|
return nil
|
|
}))
|
|
}
|
|
|
|
func TestRunInitWritesDiscoveredGuildConfig(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
t.Setenv(config.DefaultTokenEnv, "env-token")
|
|
|
|
fakeSync := &fakeSyncService{discovered: []*discordgo.UserGuild{{ID: "g1"}, {ID: "g2"}}}
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &bytes.Buffer{},
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
newDiscord: func(config.Config) (discordClient, error) { return &fakeDiscordClient{}, nil },
|
|
newSyncer: func(syncer.Client, *store.Store, *slog.Logger) syncService {
|
|
return fakeSync
|
|
},
|
|
}
|
|
|
|
require.NoError(t, rt.runInit([]string{"--db", dbPath, "--guild", "g2", "--with-embeddings"}))
|
|
cfg, err := config.Load(cfgPath)
|
|
require.NoError(t, err)
|
|
require.Equal(t, dbPath, cfg.DBPath)
|
|
require.Equal(t, []string{"g1", "g2"}, cfg.GuildIDs)
|
|
require.Equal(t, "g2", cfg.DefaultGuildID)
|
|
require.True(t, cfg.Search.Embeddings.Enabled)
|
|
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "g2")
|
|
}
|
|
|
|
func TestRunMembersShowUsesDefaultGuildForAmbiguousQuery(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.DefaultGuildID = "g1"
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{
|
|
GuildID: "g1",
|
|
UserID: "u1",
|
|
Username: "same",
|
|
DisplayName: "Same",
|
|
RoleIDsJSON: `[]`,
|
|
RawJSON: `{"github":"steipete"}`,
|
|
}))
|
|
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{
|
|
GuildID: "g2",
|
|
UserID: "u2",
|
|
Username: "same",
|
|
DisplayName: "Same",
|
|
RoleIDsJSON: `[]`,
|
|
RawJSON: `{"github":"other"}`,
|
|
}))
|
|
require.NoError(t, s.UpsertMessage(ctx, store.MessageRecord{
|
|
ID: "m1",
|
|
GuildID: "g1",
|
|
ChannelID: "c1",
|
|
ChannelName: "general",
|
|
AuthorID: "u1",
|
|
AuthorName: "Same",
|
|
MessageType: 0,
|
|
CreatedAt: time.Now().UTC().Format(time.RFC3339Nano),
|
|
Content: "hello",
|
|
NormalizedContent: "hello",
|
|
RawJSON: `{}`,
|
|
}))
|
|
require.NoError(t, s.Close())
|
|
|
|
var out bytes.Buffer
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &out,
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
}
|
|
require.NoError(t, rt.withServices(false, func() error {
|
|
return rt.runMembers([]string{"show", "same"})
|
|
}))
|
|
require.Contains(t, out.String(), "guild=g1")
|
|
require.Contains(t, out.String(), "github=steipete")
|
|
}
|
|
|
|
func TestRunMembersShowReturnsListWhenStillAmbiguous(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{GuildID: "g1", UserID: "u1", Username: "same", DisplayName: "Same", RoleIDsJSON: `[]`, RawJSON: `{}`}))
|
|
require.NoError(t, s.UpsertMember(ctx, store.MemberRecord{GuildID: "g2", UserID: "u2", Username: "same", DisplayName: "Same", RoleIDsJSON: `[]`, RawJSON: `{}`}))
|
|
require.NoError(t, s.Close())
|
|
|
|
var out bytes.Buffer
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &out,
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
}
|
|
require.NoError(t, rt.withServices(false, func() error {
|
|
return rt.runMembers([]string{"show", "same"})
|
|
}))
|
|
require.Contains(t, out.String(), "GUILD")
|
|
require.Contains(t, out.String(), "u1")
|
|
require.Contains(t, out.String(), "u2")
|
|
}
|
|
|
|
func TestRunMessagesSyncTargetsResolvedChannel(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
t.Setenv(config.DefaultTokenEnv, "env-token")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.DefaultGuildID = "g1"
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.UpsertChannel(ctx, store.ChannelRecord{ID: "c1", GuildID: "g1", Kind: "text", Name: "general", RawJSON: `{}`}))
|
|
require.NoError(t, s.Close())
|
|
|
|
fakeSync := &fakeSyncService{}
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &bytes.Buffer{},
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
openStore: store.Open,
|
|
newDiscord: func(config.Config) (discordClient, error) { return &fakeDiscordClient{}, nil },
|
|
newSyncer: func(syncer.Client, *store.Store, *slog.Logger) syncService {
|
|
return fakeSync
|
|
},
|
|
}
|
|
rt.now = func() time.Time { return time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) }
|
|
|
|
require.NoError(t, rt.withServices(true, func() error {
|
|
return rt.runMessages([]string{"--channel", "#general", "--hours", "6", "--last", "1", "--sync"})
|
|
}))
|
|
require.Equal(t, []string{"g1"}, fakeSync.lastSync.GuildIDs)
|
|
require.Equal(t, []string{"c1"}, fakeSync.lastSync.ChannelIDs)
|
|
}
|
|
|
|
func TestRunMessagesSyncFallsBackToGuildSyncForUnknownChannel(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
t.Setenv(config.DefaultTokenEnv, "env-token")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
cfg.DefaultGuildID = "g1"
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
fakeSync := &fakeSyncService{}
|
|
rt := &runtime{
|
|
ctx: ctx,
|
|
configPath: cfgPath,
|
|
stdout: &bytes.Buffer{},
|
|
stderr: &bytes.Buffer{},
|
|
logger: discardLogger(),
|
|
openStore: store.Open,
|
|
newDiscord: func(config.Config) (discordClient, error) { return &fakeDiscordClient{}, nil },
|
|
newSyncer: func(syncer.Client, *store.Store, *slog.Logger) syncService {
|
|
return fakeSync
|
|
},
|
|
}
|
|
|
|
require.NoError(t, rt.withServices(true, func() error {
|
|
return rt.runMessages([]string{"--channel", "new-channel", "--days", "1", "--sync"})
|
|
}))
|
|
require.Equal(t, []string{"g1"}, fakeSync.lastSync.GuildIDs)
|
|
require.Empty(t, fakeSync.lastSync.ChannelIDs)
|
|
}
|
|
|
|
func TestRunMentionsValidation(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := &runtime{stderr: &bytes.Buffer{}}
|
|
rt.now = func() time.Time { return time.Date(2026, 3, 8, 12, 0, 0, 0, time.UTC) }
|
|
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"extra"})))
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"--hours", "-1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"--days", "-1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"--hours", "1", "--days", "1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"--hours", "1", "--since", "2026-03-01T00:00:00Z"})))
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"--limit", "-1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"--last", "-1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"--all", "--last", "1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"--limit", "1", "--last", "1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"--since", "bad"})))
|
|
require.Equal(t, 2, ExitCode(rt.runDirectMessages([]string{"--before", "bad"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--hours", "-1", "--channel", "general"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--hours", "1", "--days", "1", "--channel", "general"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--hours", "1", "--since", "2026-03-01T00:00:00Z", "--channel", "general"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--last", "-1", "--channel", "general"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--last", "1", "--limit", "20", "--channel", "general"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--last", "1", "--all", "--channel", "general"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMentions([]string{"--days", "-1", "--target", "u1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMentions([]string{"--days", "1", "--since", "2026-03-01T00:00:00Z", "--target", "u1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMentions([]string{"--since", "bad", "--target", "u1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMentions([]string{"--type", "nope", "--target", "u1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMentions([]string{})))
|
|
}
|
|
|
|
func TestPrintJSONAndPlain(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := &runtime{stdout: &bytes.Buffer{}, json: true}
|
|
require.NoError(t, rt.print(map[string]any{"ok": true}))
|
|
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "\"ok\": true")
|
|
|
|
rt = &runtime{stdout: &bytes.Buffer{}, plain: true}
|
|
require.NoError(t, rt.print([]store.ChannelRow{{GuildID: "g1", ID: "c1", Kind: "text", Name: "general"}}))
|
|
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "general")
|
|
|
|
rt = &runtime{stdout: &bytes.Buffer{}}
|
|
require.NoError(t, rt.print([]store.SearchResult{{GuildID: "g1", ChannelName: "general", AuthorName: "Peter", Content: "hello"}}))
|
|
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "hello")
|
|
|
|
rt = &runtime{stdout: &bytes.Buffer{}}
|
|
require.NoError(t, rt.print([]store.MentionRow{{GuildID: "g1", ChannelName: "general", AuthorName: "Peter", TargetType: "user", TargetName: "Shadow", Content: "hello"}}))
|
|
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "Shadow")
|
|
|
|
rt = &runtime{stdout: &bytes.Buffer{}, plain: true}
|
|
require.NoError(t, rt.print([]store.MemberRow{{GuildID: "g1", UserID: "u1", Username: "peter"}}))
|
|
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "peter")
|
|
|
|
rt = &runtime{stdout: &bytes.Buffer{}, plain: true}
|
|
require.NoError(t, rt.print([]store.SearchResult{{GuildID: "g1", ChannelID: "c1", AuthorID: "u1", Content: "hello"}}))
|
|
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "hello")
|
|
|
|
rt = &runtime{stdout: &bytes.Buffer{}, plain: true}
|
|
require.NoError(t, rt.print([]store.MessageRow{{GuildID: "g1", ChannelID: "c1", AuthorID: "u1", MessageID: "m1", Content: "hello", CreatedAt: time.Unix(1, 0).UTC()}}))
|
|
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "m1")
|
|
|
|
rt = &runtime{stdout: &bytes.Buffer{}, plain: true}
|
|
require.NoError(t, rt.print([]store.MentionRow{{GuildID: "g1", ChannelID: "c1", AuthorID: "u1", TargetType: "user", TargetID: "u2", Content: "hello", CreatedAt: time.Unix(1, 0).UTC()}}))
|
|
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "u2")
|
|
|
|
rt = &runtime{stdout: &bytes.Buffer{}}
|
|
require.NoError(t, rt.print(struct{ OK bool }{OK: true}))
|
|
require.Contains(t, rt.stdout.(*bytes.Buffer).String(), "\"OK\": true")
|
|
}
|
|
|
|
func TestWithServicesErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := &runtime{ctx: context.Background(), configPath: filepath.Join(t.TempDir(), "missing.toml"), stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}}
|
|
err := rt.withServices(false, func() error { return nil })
|
|
require.Equal(t, 3, ExitCode(err))
|
|
|
|
cfgPath := filepath.Join(t.TempDir(), "config.toml")
|
|
cfg := config.Default()
|
|
cfg.DBPath = filepath.Join(t.TempDir(), "discrawl.db")
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
rt = &runtime{
|
|
ctx: context.Background(),
|
|
configPath: cfgPath,
|
|
stdout: &bytes.Buffer{},
|
|
stderr: &bytes.Buffer{},
|
|
openStore: func(context.Context, string) (*store.Store, error) {
|
|
return nil, assertErr("db")
|
|
},
|
|
}
|
|
err = rt.withServices(false, func() error { return nil })
|
|
require.Equal(t, 5, ExitCode(err))
|
|
|
|
rt = &runtime{
|
|
ctx: context.Background(),
|
|
configPath: cfgPath,
|
|
stdout: &bytes.Buffer{},
|
|
stderr: &bytes.Buffer{},
|
|
openStore: store.Open,
|
|
newDiscord: func(config.Config) (discordClient, error) { return nil, assertErr("auth") },
|
|
}
|
|
err = rt.withServices(true, func() error { return nil })
|
|
require.Equal(t, 4, ExitCode(err))
|
|
}
|
|
|
|
func TestCommandUsageErrors(t *testing.T) {
|
|
t.Parallel()
|
|
|
|
rt := &runtime{}
|
|
require.Equal(t, 2, ExitCode(rt.runMembers(nil)))
|
|
require.Equal(t, 2, ExitCode(rt.runMembers([]string{"nope"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMessages(nil)))
|
|
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--days", "-1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runMessages([]string{"--days", "1", "--since", "2026-03-01T00:00:00Z"})))
|
|
require.Equal(t, 2, ExitCode(rt.runSync([]string{"--all", "--guild", "g1"})))
|
|
require.Equal(t, 2, ExitCode(rt.runSync([]string{"--update", "bogus"})))
|
|
require.Equal(t, 2, ExitCode(rt.runSync([]string{"--update=force", "--no-update"})))
|
|
require.Equal(t, 2, ExitCode(rt.runChannels(nil)))
|
|
require.Equal(t, 2, ExitCode(rt.runStatus([]string{"extra"})))
|
|
require.NoError(t, (&runtime{stdout: &bytes.Buffer{}}).runDoctor(nil))
|
|
}
|
|
|
|
func TestRunSQLReadsStdin(t *testing.T) {
|
|
ctx := context.Background()
|
|
dir := t.TempDir()
|
|
cfgPath := filepath.Join(dir, "config.toml")
|
|
dbPath := filepath.Join(dir, "discrawl.db")
|
|
|
|
cfg := config.Default()
|
|
cfg.DBPath = dbPath
|
|
require.NoError(t, config.Write(cfgPath, cfg))
|
|
|
|
s, err := store.Open(ctx, dbPath)
|
|
require.NoError(t, err)
|
|
require.NoError(t, s.Close())
|
|
|
|
oldStdin := os.Stdin
|
|
defer func() { os.Stdin = oldStdin }()
|
|
file, err := os.CreateTemp(dir, "stdin.sql")
|
|
require.NoError(t, err)
|
|
_, err = file.WriteString("select 1 as one")
|
|
require.NoError(t, err)
|
|
require.NoError(t, file.Close())
|
|
file, err = os.Open(file.Name())
|
|
require.NoError(t, err)
|
|
os.Stdin = file
|
|
|
|
rt := &runtime{ctx: ctx, configPath: cfgPath, stdout: &bytes.Buffer{}, stderr: &bytes.Buffer{}, logger: discardLogger()}
|
|
require.NoError(t, rt.withServices(false, func() error { return rt.runSQL([]string{"-"}) }))
|
|
}
|