285 lines
10 KiB
Go
285 lines
10 KiB
Go
package telegram
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"codex-telegram-bot/internal/store"
|
|
)
|
|
|
|
func TestEscapeHTML(t *testing.T) {
|
|
got := EscapeHTML(`<run & "test">`)
|
|
want := "<run & "test">"
|
|
if got != want {
|
|
t.Fatalf("EscapeHTML() = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
func TestChunkText(t *testing.T) {
|
|
text := strings.Repeat("a", 25)
|
|
chunks := ChunkText(text, 10)
|
|
if len(chunks) != 3 {
|
|
t.Fatalf("got %d chunks", len(chunks))
|
|
}
|
|
for _, chunk := range chunks {
|
|
if len([]rune(chunk)) > 10 {
|
|
t.Fatalf("chunk too long: %q", chunk)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTelegramClientRedactsToken(t *testing.T) {
|
|
client := NewClient("secret-token")
|
|
err := client.redactError(fmt.Errorf("Post %q: context canceled", client.baseURL+"/sendMessage"))
|
|
if strings.Contains(err.Error(), "secret-token") {
|
|
t.Fatalf("token was not redacted: %v", err)
|
|
}
|
|
if !strings.Contains(err.Error(), "<telegram-token>") {
|
|
t.Fatalf("redacted token marker missing: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestApprovalCallbackData(t *testing.T) {
|
|
data := ApprovalCallbackData(12, "accept")
|
|
id, decision, ok := ParseApprovalCallbackData(data)
|
|
if !ok || id != 12 || decision != "accept" {
|
|
t.Fatalf("unexpected callback parse: id=%d decision=%s ok=%v", id, decision, ok)
|
|
}
|
|
if _, _, ok := ParseApprovalCallbackData("approval:12:unknown"); ok {
|
|
t.Fatal("unknown decisions should be rejected")
|
|
}
|
|
}
|
|
|
|
func TestApprovalResponseForPermissions(t *testing.T) {
|
|
approval := store.PendingApproval{
|
|
Kind: "item/permissions/requestApproval",
|
|
PayloadJSON: `{"permissions":{"network":{"enabled":true}}}`,
|
|
}
|
|
response, ok := approvalResponse(approval, "accept").(map[string]any)
|
|
if !ok {
|
|
t.Fatal("approval response should be a map")
|
|
}
|
|
if response["scope"] != "turn" {
|
|
t.Fatalf("scope = %v, want turn", response["scope"])
|
|
}
|
|
permissions, ok := response["permissions"].(map[string]any)
|
|
if !ok {
|
|
t.Fatal("permissions should be a map")
|
|
}
|
|
network, ok := permissions["network"].(map[string]any)
|
|
if !ok || network["enabled"] != true {
|
|
t.Fatalf("unexpected permissions: %#v", permissions)
|
|
}
|
|
|
|
denied, ok := approvalResponse(approval, "decline").(map[string]any)
|
|
if !ok {
|
|
t.Fatal("denied response should be a map")
|
|
}
|
|
deniedPermissions, ok := denied["permissions"].(map[string]any)
|
|
if !ok || len(deniedPermissions) != 0 {
|
|
t.Fatalf("denied permissions = %#v, want empty map", denied["permissions"])
|
|
}
|
|
}
|
|
|
|
func TestParseCommand(t *testing.T) {
|
|
name, args, ok := parseCommand("/resume@my_bot 123")
|
|
if !ok || name != "resume" || len(args) != 1 || args[0] != "123" {
|
|
t.Fatalf("unexpected command parse: %q %#v %v", name, args, ok)
|
|
}
|
|
}
|
|
|
|
func TestPictureGenerationInstruction(t *testing.T) {
|
|
instruction := pictureGenerationInstruction("generate a blue cube")
|
|
for _, want := range []string{"Telegram /pic command", "built-in image generation", "generate a blue cube"} {
|
|
if !strings.Contains(instruction, want) {
|
|
t.Fatalf("instruction missing %q in %q", want, instruction)
|
|
}
|
|
}
|
|
for _, unwanted := range []string{"/home", "repo/playground"} {
|
|
if strings.Contains(instruction, unwanted) {
|
|
t.Fatalf("instruction contains non-portable text %q: %q", unwanted, instruction)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSplitAssistantMessageSegmentsWithPhotoDirective(t *testing.T) {
|
|
photoPath := filepath.Join(string(filepath.Separator), "workspace", "photo.jpg")
|
|
text := fmt.Sprintf("before\n<!-- telegram-photo {\"path\":%q,\"caption\":\"hello\"} -->\nafter", photoPath)
|
|
segments := splitAssistantMessageSegments(text)
|
|
if len(segments) != 3 {
|
|
t.Fatalf("segments = %d, want 3: %#v", len(segments), segments)
|
|
}
|
|
if segments[0].Text != "before\n" || segments[0].Photo != nil {
|
|
t.Fatalf("unexpected first segment: %#v", segments[0])
|
|
}
|
|
if segments[1].Photo == nil || segments[1].Photo.Path != photoPath || segments[1].Photo.Caption != "hello" {
|
|
t.Fatalf("unexpected photo segment: %#v", segments[1])
|
|
}
|
|
if segments[2].Text != "after" || segments[2].Photo != nil {
|
|
t.Fatalf("unexpected final segment: %#v", segments[2])
|
|
}
|
|
}
|
|
|
|
func TestInvalidPhotoDirectiveStaysVisible(t *testing.T) {
|
|
text := "<!-- telegram-photo not-json -->"
|
|
segments := splitAssistantMessageSegments(text)
|
|
if len(segments) != 1 || segments[0].Text != text {
|
|
t.Fatalf("invalid directive should stay text: %#v", segments)
|
|
}
|
|
}
|
|
|
|
func TestSplitAssistantMessageSegmentsWithThreadDirectives(t *testing.T) {
|
|
cwd := filepath.Join(string(filepath.Separator), "workspace", "project")
|
|
text := fmt.Sprintf("<!-- codex-thread-rename {\"title\":\" A Better Thread Title \"} -->\n<!-- codex-thread-cwd {\"cwd\":%q} -->", cwd)
|
|
segments := splitAssistantMessageSegments(text)
|
|
if len(segments) != 2 {
|
|
t.Fatalf("segments = %d, want 2: %#v", len(segments), segments)
|
|
}
|
|
if segments[0].ThreadRename == nil || segments[0].ThreadRename.Title != "A Better Thread Title" {
|
|
t.Fatalf("unexpected rename segment: %#v", segments[0])
|
|
}
|
|
if segments[1].ThreadCWD == nil || segments[1].ThreadCWD.CWD != cwd {
|
|
t.Fatalf("unexpected cwd segment: %#v", segments[1])
|
|
}
|
|
}
|
|
|
|
func TestRenderCodexCommandExecutionItem(t *testing.T) {
|
|
output := "line 1\nline 2"
|
|
exitCode := 0
|
|
item := codexThreadItemView{
|
|
Type: "commandExecution",
|
|
Command: "go test ./...",
|
|
AggregatedOutput: &output,
|
|
ExitCode: &exitCode,
|
|
}
|
|
text := renderCodexItemCompleted(item)
|
|
for _, want := range []string{"Tool call: command finished", "<b>Command</b>", "<pre><code class=\"language-bash\">go test ./...</code></pre>", "Exit code: 0", "<pre><code class=\"language-text\">line 1\nline 2</code></pre>"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("rendered command item missing %q in %q", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRenderCodexStartedItems(t *testing.T) {
|
|
text := renderCodexItemStarted(codexThreadItemView{Type: "webSearch", Query: "telegram bot api"})
|
|
if !strings.Contains(text, "web search started") || !strings.Contains(text, "telegram bot api") {
|
|
t.Fatalf("unexpected web search render: %q", text)
|
|
}
|
|
}
|
|
|
|
func TestRenderDynamicToolDetailsSelectsUsefulArguments(t *testing.T) {
|
|
item := codexThreadItemView{
|
|
Type: "dynamicToolCall",
|
|
Namespace: "functions",
|
|
Tool: "exec_command",
|
|
Status: "completed",
|
|
Arguments: json.RawMessage(`{"cmd":"go test ./...","irrelevant":{"large":"object"}}`),
|
|
}
|
|
text := renderCodexItemCompleted(item)
|
|
for _, want := range []string{"Tool: functions.exec_command", "<b>cmd</b>", "language-bash", "go test ./..."} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("rendered tool details missing %q in %q", want, text)
|
|
}
|
|
}
|
|
if strings.Contains(text, "irrelevant") {
|
|
t.Fatalf("rendered tool details should omit irrelevant argument JSON: %q", text)
|
|
}
|
|
}
|
|
|
|
func TestRenderApprovalDetailsAvoidsRawJSONDump(t *testing.T) {
|
|
raw := json.RawMessage(`{"command":"go test ./...","cwd":"/workspace/project","unused":{"nested":true}}`)
|
|
text := renderApprovalHTML("item/commandExecution/requestApproval", raw, "")
|
|
for _, want := range []string{"Codex requests command approval", "language-bash", "go test ./...", "CWD"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("approval render missing %q in %q", want, text)
|
|
}
|
|
}
|
|
if strings.Contains(text, "unused") {
|
|
t.Fatalf("approval render should omit unused JSON: %q", text)
|
|
}
|
|
}
|
|
|
|
func TestToolMessageAddsEditedAtBeforeDetails(t *testing.T) {
|
|
tool := toolMessageState{
|
|
toolHTML: SummaryDetailsHTML("Tool call: command finished\nCommand: go test ./...", "full output"),
|
|
editedAt: "2026-05-21 12:34:56 UTC",
|
|
}
|
|
text := tool.html()
|
|
want := "Command: go test ./...\nEdited at: 2026-05-21 12:34:56 UTC\n<blockquote expandable>"
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("edited timestamp not placed before details: %q", text)
|
|
}
|
|
}
|
|
|
|
func TestToolMessageFitsCombinedApprovalDetails(t *testing.T) {
|
|
largeToolDetails := strings.Repeat("tool output line\n", 600)
|
|
largeApprovalDetails := strings.Repeat("approval payload line\n", 600)
|
|
tool := toolMessageState{
|
|
toolHTML: SummaryDetailsHTML("Tool call: command finished", largeToolDetails),
|
|
approvalHTML: SummaryDetailsHTML("Codex requests command approval", largeApprovalDetails),
|
|
editedAt: "2026-05-21 12:34:56 UTC",
|
|
}
|
|
text := tool.html()
|
|
if len([]rune(text)) > TelegramHTMLMessageLimit {
|
|
t.Fatalf("tool message exceeds Telegram limit: %d", len([]rune(text)))
|
|
}
|
|
for _, want := range []string{"Tool call: command finished", "Codex requests command approval", "Edited at: 2026-05-21 12:34:56 UTC", "...[truncated]"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("fitted tool message missing %q in %q", want, text)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestResumeCallbackData(t *testing.T) {
|
|
threadID, ok := ParseResumeThreadCallbackData(ResumeThreadCallbackData(123))
|
|
if !ok || threadID != 123 {
|
|
t.Fatalf("unexpected resume thread callback: id=%d ok=%v", threadID, ok)
|
|
}
|
|
page, ok := ParseResumePageCallbackData(ResumePageCallbackData(2))
|
|
if !ok || page != 2 {
|
|
t.Fatalf("unexpected resume page callback: page=%d ok=%v", page, ok)
|
|
}
|
|
}
|
|
|
|
func TestResumeThreadListText(t *testing.T) {
|
|
threads := []store.Thread{{ID: 42, Title: "do xyz"}, {ID: 43, Title: "executed xxx command"}}
|
|
text := resumeThreadListText(threads, 0)
|
|
for _, want := range []string{"Thread ID 42: do xyz", "Thread ID 43: executed xxx command"} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("resume list missing %q in %q", want, text)
|
|
}
|
|
}
|
|
markup := resumeThreadMarkup(threads, 0, true)
|
|
if len(markup.InlineKeyboard) != 2 || markup.InlineKeyboard[0][0].Text != "ID 42" || markup.InlineKeyboard[0][1].Text != "ID 43" {
|
|
t.Fatalf("unexpected resume buttons: %#v", markup.InlineKeyboard)
|
|
}
|
|
firstID, ok := ParseResumeThreadCallbackData(markup.InlineKeyboard[0][0].CallbackData)
|
|
if !ok || firstID != 42 {
|
|
t.Fatalf("first resume button targets id=%d ok=%v", firstID, ok)
|
|
}
|
|
secondID, ok := ParseResumeThreadCallbackData(markup.InlineKeyboard[0][1].CallbackData)
|
|
if !ok || secondID != 43 {
|
|
t.Fatalf("second resume button targets id=%d ok=%v", secondID, ok)
|
|
}
|
|
}
|
|
|
|
func TestModelAndEffortCallbackData(t *testing.T) {
|
|
modelID := strings.Join([]string{"server", "model", "id"}, "-")
|
|
data, ok := ModelCallbackData(modelID)
|
|
if !ok {
|
|
t.Fatal("model callback should fit")
|
|
}
|
|
model, ok := ParseModelCallbackData(data)
|
|
if !ok || model != modelID {
|
|
t.Fatalf("unexpected model callback parse: model=%q ok=%v", model, ok)
|
|
}
|
|
effortName := strings.Join([]string{"server", "effort"}, "-")
|
|
effort, ok := ParseEffortCallbackData(EffortCallbackData(effortName))
|
|
if !ok || effort != effortName {
|
|
t.Fatalf("unexpected effort callback parse: effort=%q ok=%v", effort, ok)
|
|
}
|
|
}
|