Treat app-server request IDs as connection-local by reopening reused approval rows when the thread, turn, or item context changes. Keep duplicate resolved approvals in the same context closed, and add focused approval-path diagnostics without changing the Telegram approval UI.
342 lines
9.1 KiB
Go
342 lines
9.1 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestStoreUsersWorkspacesSessions(t *testing.T) {
|
|
ctx := context.Background()
|
|
st, err := Open(ctx, filepath.Join(t.TempDir(), "bot.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
allowed, err := st.IsAllowed(ctx, 42)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if allowed {
|
|
t.Fatal("new user should not be allowed")
|
|
}
|
|
if err := st.AddAllowedUser(ctx, 42, "alice", "owner"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
allowed, err = st.IsAllowed(ctx, 42)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !allowed {
|
|
t.Fatal("user should be allowed")
|
|
}
|
|
|
|
workspacePath := t.TempDir()
|
|
ws, err := st.AddWorkspace(ctx, workspacePath, "repo", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !ws.IsDefault {
|
|
t.Fatal("workspace should be default")
|
|
}
|
|
|
|
session, err := st.GetOrCreateSession(ctx, 42, "test-model", "read-only")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.Model != "test-model" || session.Sandbox != "read-only" || session.ReasoningEffort != "" {
|
|
t.Fatalf("unexpected session defaults: %+v", session)
|
|
}
|
|
if err := st.SetSessionWorkspace(ctx, 42, ws.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
effort := "value-returned-by-server"
|
|
if err := st.SetSessionReasoningEffort(ctx, 42, effort); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err = st.GetSession(ctx, 42)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.ActiveWorkspaceID != ws.ID || session.ReasoningEffort != effort {
|
|
t.Fatalf("workspace not saved: %+v", session)
|
|
}
|
|
if err := st.SetSessionSettingsMessage(ctx, 42, 1001, 2002); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err = st.GetSession(ctx, 42)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.SettingsChatID != 1001 || session.SettingsMessageID != 2002 {
|
|
t.Fatalf("settings message not saved: %+v", session)
|
|
}
|
|
thread, err := st.CreateThread(ctx, 42, "codex-thread-123", ws.ID, "test thread")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetActiveThread(ctx, 42, thread.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SetActiveTurn(ctx, 42, "turn-123"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
turns, err := st.ListActiveTurns(ctx)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(turns) != 1 || turns[0].TelegramUserID != 42 || turns[0].CodexThreadID != "codex-thread-123" || turns[0].TurnID != "turn-123" {
|
|
t.Fatalf("active turns not listed: %+v", turns)
|
|
}
|
|
if err := st.ClearActiveTurn(ctx, 42, "other-turn"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err = st.GetSession(ctx, 42)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.ActiveTurnID != "turn-123" {
|
|
t.Fatalf("wrong turn cleared: %+v", session)
|
|
}
|
|
if err := st.ClearActiveTurns(ctx); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
session, err = st.GetSession(ctx, 42)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if session.ActiveTurnID != "" {
|
|
t.Fatalf("active turn not cleared: %+v", session)
|
|
}
|
|
}
|
|
|
|
func TestListThreadsPage(t *testing.T) {
|
|
ctx := context.Background()
|
|
st, err := Open(ctx, filepath.Join(t.TempDir(), "bot.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
ws, err := st.AddWorkspace(ctx, t.TempDir(), "repo", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i := 0; i < 3; i++ {
|
|
if _, err := st.CreateThread(ctx, 42, string(rune('a'+i)), ws.ID, "thread"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
threads, err := st.ListThreadsPage(ctx, 42, false, 2, 0)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(threads) != 2 {
|
|
t.Fatalf("got %d threads, want 2", len(threads))
|
|
}
|
|
threads, err = st.ListThreadsPage(ctx, 42, false, 2, 2)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(threads) != 1 {
|
|
t.Fatalf("got %d threads on second page, want 1", len(threads))
|
|
}
|
|
}
|
|
|
|
func TestRenameThread(t *testing.T) {
|
|
ctx := context.Background()
|
|
st, err := Open(ctx, filepath.Join(t.TempDir(), "bot.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
ws, err := st.AddWorkspace(ctx, t.TempDir(), "repo", true)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
thread, err := st.CreateThread(ctx, 42, "codex-thread", ws.ID, "old title")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.RenameThread(ctx, 42, thread.ID, "new title"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
thread, err = st.GetThreadByID(ctx, 42, thread.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if thread.Title != "new title" {
|
|
t.Fatalf("title = %q", thread.Title)
|
|
}
|
|
if err := st.RenameThreadByCodexID(ctx, "codex-thread", "codex title"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
thread, err = st.GetThreadByID(ctx, 42, thread.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if thread.Title != "codex title" {
|
|
t.Fatalf("title = %q", thread.Title)
|
|
}
|
|
if err := st.SyncThreadTitle(ctx, 42, thread.ID, "synced title"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
thread, err = st.GetThreadByID(ctx, 42, thread.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if thread.Title != "synced title" {
|
|
t.Fatalf("title = %q", thread.Title)
|
|
}
|
|
if err := st.SyncThreadTitleByCodexID(ctx, "codex-thread", "synced codex title"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
thread, err = st.GetThreadByID(ctx, 42, thread.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if thread.Title != "synced codex title" {
|
|
t.Fatalf("title = %q", thread.Title)
|
|
}
|
|
ws2, err := st.AddWorkspace(ctx, t.TempDir(), "repo2", false)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.SyncThreadWorkspace(ctx, 42, thread.ID, ws2.ID); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
thread, err = st.GetThreadByID(ctx, 42, thread.ID)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if thread.WorkspaceID != ws2.ID {
|
|
t.Fatalf("workspace id = %d, want %d", thread.WorkspaceID, ws2.ID)
|
|
}
|
|
}
|
|
|
|
func TestUpsertPendingApprovalDoesNotReopenResolved(t *testing.T) {
|
|
ctx := context.Background()
|
|
st, err := Open(ctx, filepath.Join(t.TempDir(), "bot.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
first, err := st.UpsertPendingApproval(ctx, PendingApproval{
|
|
TelegramUserID: 42,
|
|
CodexRequestID: "request-1",
|
|
CodexThreadID: "thread-1",
|
|
Kind: "item/commandExecution/requestApproval",
|
|
PayloadJSON: `{"command":"go test ./..."}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpdatePendingApprovalMessage(ctx, first.ID, 1001, 2002); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ResolvePendingApproval(ctx, 42, first.ID, "accept"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
second, err := st.UpsertPendingApproval(ctx, PendingApproval{
|
|
TelegramUserID: 42,
|
|
CodexRequestID: "request-1",
|
|
CodexThreadID: "thread-1",
|
|
Kind: "item/commandExecution/requestApproval",
|
|
PayloadJSON: `{"command":"go test ./...","duplicate":true}`,
|
|
MessageChatID: 3003,
|
|
MessageID: 4004,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second.ID != first.ID {
|
|
t.Fatalf("duplicate approval id = %d, want %d", second.ID, first.ID)
|
|
}
|
|
if second.Status != "accept" {
|
|
t.Fatalf("duplicate approval status = %q, want accept", second.Status)
|
|
}
|
|
if second.MessageChatID != 1001 || second.MessageID != 2002 {
|
|
t.Fatalf("resolved approval message changed: chat=%d message=%d", second.MessageChatID, second.MessageID)
|
|
}
|
|
if second.ResolvedAt == "" {
|
|
t.Fatal("resolved approval lost resolved_at")
|
|
}
|
|
}
|
|
|
|
func TestUpsertPendingApprovalReopensReusedRequestIDForNewContext(t *testing.T) {
|
|
ctx := context.Background()
|
|
st, err := Open(ctx, filepath.Join(t.TempDir(), "bot.db"))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer st.Close()
|
|
|
|
first, err := st.UpsertPendingApproval(ctx, PendingApproval{
|
|
TelegramUserID: 42,
|
|
CodexRequestID: "i:0",
|
|
CodexThreadID: "thread-1",
|
|
TurnID: "turn-1",
|
|
ItemID: "item-1",
|
|
Kind: "item/commandExecution/requestApproval",
|
|
PayloadJSON: `{"command":"go test ./..."}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.UpdatePendingApprovalMessage(ctx, first.ID, 1001, 2002); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := st.ResolvePendingApproval(ctx, 42, first.ID, "accept"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
second, err := st.UpsertPendingApproval(ctx, PendingApproval{
|
|
TelegramUserID: 42,
|
|
CodexRequestID: "i:0",
|
|
CodexThreadID: "thread-2",
|
|
TurnID: "turn-2",
|
|
ItemID: "item-2",
|
|
Kind: "item/commandExecution/requestApproval",
|
|
PayloadJSON: `{"command":"git push"}`,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if second.ID != first.ID {
|
|
t.Fatalf("reused request id row = %d, want %d", second.ID, first.ID)
|
|
}
|
|
if second.Status != "pending" {
|
|
t.Fatalf("reused request status = %q, want pending", second.Status)
|
|
}
|
|
if second.CodexThreadID != "thread-2" || second.TurnID != "turn-2" || second.ItemID != "item-2" {
|
|
t.Fatalf("reused request context = thread %q turn %q item %q", second.CodexThreadID, second.TurnID, second.ItemID)
|
|
}
|
|
if second.MessageChatID != 0 || second.MessageID != 0 {
|
|
t.Fatalf("reused request kept stale message: chat=%d message=%d", second.MessageChatID, second.MessageID)
|
|
}
|
|
if second.ResolvedAt != "" {
|
|
t.Fatalf("reused request resolved_at = %q, want empty", second.ResolvedAt)
|
|
}
|
|
}
|
|
|
|
func TestValidateWorkspacePath(t *testing.T) {
|
|
if _, err := ValidateWorkspacePath("relative/path"); err == nil {
|
|
t.Fatal("relative path should be rejected")
|
|
}
|
|
if _, err := ValidateWorkspacePath("/"); err == nil {
|
|
t.Fatal("filesystem root should be rejected")
|
|
}
|
|
input := string(filepath.Separator) + filepath.Join("tmp", "..", "tmp", "project")
|
|
clean, err := ValidateWorkspacePath(input)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if clean != filepath.Join(string(filepath.Separator), "tmp", "project") {
|
|
t.Fatalf("unexpected clean path: %s", clean)
|
|
}
|
|
}
|