Files
kandev/apps/backend/internal/slack/store_test.go
T
João Salavisa 7e0b46c227 feat(slack): cookie-mode integration that triages threads via a utility agent (#775)
* feat(slack): add cookie-mode integration that triages messages via utility agent

Adds an install-wide Slack integration that polls the connected user's
own messages for `!kandev <instruction>`, hands the thread context to a
configured utility agent with the Kandev MCP server attached, and
creates the resulting task. Auth uses the user's xoxc- token + d cookie
so it works on locked-down Enterprise Slack tenants where bot installs
aren't possible.

Shared infra:
- ACP host-utility executor accepts McpServers on session/new
- New MCP tool list_repositories_kandev so the triage agent can pick the
  right repo
- Template engine exposes Slack-specific variables for prompt injection
- secretadapter + healthpoll reused from jira/linear

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(slack): address PR review feedback round 9

- Drop dead `var _ = time.Second` no-op and its misleading comment in
  trigger_test.go.
- `RecordAuthHealth` now invalidates the cached client when the probe
  fails, so the poller self-recovers from credential rotation without
  requiring a re-save.
- Stable secondary sort key (`workspace_id DESC`) on the legacy →
  singleton migration so promoted-row selection is deterministic on
  `updated_at` ties.
- Extract `categorySlack` constant in `engine.go` and update the
  `VariableInfo.Category` doc-comment to list the full set of values.
- Realign `slack-integration/spec.md` with what shipped: cookie-mode +
  `!kandev` triage as v1, with bot install / User OAuth / reaction &
  slash & shortcut triggers moved under Future scope.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-05 09:03:42 +01:00

251 lines
7.3 KiB
Go

package slack
import (
"context"
"database/sql"
"testing"
"time"
"github.com/jmoiron/sqlx"
_ "github.com/mattn/go-sqlite3"
)
func newTestStore(t *testing.T) *Store {
t.Helper()
raw, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
raw.SetMaxOpenConns(1)
raw.SetMaxIdleConns(1)
db := sqlx.NewDb(raw, "sqlite3")
t.Cleanup(func() { _ = db.Close() })
store, err := NewStore(db, db)
if err != nil {
t.Fatalf("new store: %v", err)
}
return store
}
func TestStore_UpsertGetDelete(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
cfg := &SlackConfig{
AuthMethod: AuthMethodCookie,
CommandPrefix: "!kandev",
UtilityAgentID: "ua-1",
PollIntervalSeconds: 30,
}
if err := store.UpsertConfig(ctx, cfg); err != nil {
t.Fatalf("upsert: %v", err)
}
got, err := store.GetConfig(ctx)
if err != nil {
t.Fatalf("get: %v", err)
}
if got == nil {
t.Fatal("expected config, got nil")
}
if got.UtilityAgentID != "ua-1" || got.CommandPrefix != "!kandev" || got.PollIntervalSeconds != 30 {
t.Errorf("round-trip mismatch: %+v", got)
}
if got.CreatedAt.IsZero() || got.UpdatedAt.IsZero() {
t.Error("timestamps not set")
}
cfg.UtilityAgentID = "ua-2"
cfg.CommandPrefix = "!todo"
if err := store.UpsertConfig(ctx, cfg); err != nil {
t.Fatalf("update upsert: %v", err)
}
got2, _ := store.GetConfig(ctx)
if got2.UtilityAgentID != "ua-2" || got2.CommandPrefix != "!todo" {
t.Errorf("expected updates, got %+v", got2)
}
if err := store.DeleteConfig(ctx); err != nil {
t.Fatalf("delete: %v", err)
}
gone, err := store.GetConfig(ctx)
if err != nil {
t.Fatalf("get after delete: %v", err)
}
if gone != nil {
t.Errorf("expected nil after delete, got %+v", gone)
}
}
func TestStore_GetConfig_Missing(t *testing.T) {
store := newTestStore(t)
cfg, err := store.GetConfig(context.Background())
if err != nil {
t.Fatalf("get: %v", err)
}
if cfg != nil {
t.Errorf("expected nil for missing config, got %+v", cfg)
}
}
func TestStore_HasConfig(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
has, err := store.HasConfig(ctx)
if err != nil || has {
t.Fatalf("expected no config, got has=%v err=%v", has, err)
}
_ = store.UpsertConfig(ctx, &SlackConfig{
AuthMethod: AuthMethodCookie,
UtilityAgentID: "ua",
PollIntervalSeconds: 30,
})
has, err = store.HasConfig(ctx)
if err != nil || !has {
t.Fatalf("expected has=true after upsert, got has=%v err=%v", has, err)
}
}
func TestStore_UpdateAuthHealth(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
_ = store.UpsertConfig(ctx, &SlackConfig{
AuthMethod: AuthMethodCookie,
UtilityAgentID: "ua",
PollIntervalSeconds: 30,
})
now := time.Now().UTC()
if err := store.UpdateAuthHealth(ctx, true, "", "T0001", "U0001", now); err != nil {
t.Fatalf("update auth health: %v", err)
}
got, _ := store.GetConfig(ctx)
if !got.LastOk || got.SlackTeamID != "T0001" || got.SlackUserID != "U0001" {
t.Errorf("expected health to be recorded, got %+v", got)
}
if err := store.UpdateAuthHealth(ctx, false, "expired", "", "", now.Add(time.Minute)); err != nil {
t.Fatalf("update auth health 2: %v", err)
}
got2, _ := store.GetConfig(ctx)
if got2.LastOk {
t.Error("expected lastOk false")
}
if got2.LastError != "expired" {
t.Errorf("expected error message, got %q", got2.LastError)
}
if got2.SlackTeamID != "T0001" || got2.SlackUserID != "U0001" {
t.Errorf("expected captured ids preserved across failure, got team=%q user=%q",
got2.SlackTeamID, got2.SlackUserID)
}
}
func TestStore_UpdateLastSeenTS(t *testing.T) {
store := newTestStore(t)
ctx := context.Background()
_ = store.UpsertConfig(ctx, &SlackConfig{
AuthMethod: AuthMethodCookie,
UtilityAgentID: "ua",
PollIntervalSeconds: 30,
})
if err := store.UpdateLastSeenTS(ctx, "1714659000.000100"); err != nil {
t.Fatalf("update ts: %v", err)
}
got, _ := store.GetConfig(ctx)
if got.LastSeenTS != "1714659000.000100" {
t.Errorf("expected ts persisted, got %q", got.LastSeenTS)
}
}
func TestStore_LegacyPerWorkspaceMigration(t *testing.T) {
// Simulate a deployment running the pre-singleton schema: a slack_configs
// table keyed by workspace_id, with the most-recently-updated row to be
// promoted into the singleton on upgrade.
raw, err := sql.Open("sqlite3", ":memory:")
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
raw.SetMaxOpenConns(1)
raw.SetMaxIdleConns(1)
db := sqlx.NewDb(raw, "sqlite3")
t.Cleanup(func() { _ = db.Close() })
if _, err := db.Exec(`
CREATE TABLE slack_configs (
workspace_id TEXT PRIMARY KEY,
auth_method TEXT NOT NULL,
command_prefix TEXT NOT NULL DEFAULT '',
utility_agent_id TEXT NOT NULL DEFAULT '',
poll_interval_seconds INTEGER NOT NULL DEFAULT 30,
slack_team_id TEXT NOT NULL DEFAULT '',
slack_user_id TEXT NOT NULL DEFAULT '',
last_seen_ts TEXT NOT NULL DEFAULT '',
last_checked_at DATETIME,
last_ok INTEGER NOT NULL DEFAULT 0,
last_error TEXT NOT NULL DEFAULT '',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)`); err != nil {
t.Fatalf("create legacy table: %v", err)
}
now := time.Now().UTC()
older := now.Add(-time.Hour)
if _, err := db.Exec(`
INSERT INTO slack_configs (workspace_id, auth_method, command_prefix, utility_agent_id,
poll_interval_seconds, slack_team_id, slack_user_id, last_seen_ts,
last_checked_at, last_ok, last_error, created_at, updated_at)
VALUES
('ws-old', 'cookie', '!kandev', 'ua-old', 30, 'T-old', 'U-old', '100.0', ?, 1, '', ?, ?),
('ws-new', 'cookie', '!kandev', 'ua-new', 60, 'T-new', 'U-new', '200.0', ?, 1, '', ?, ?)`,
older, older, older, now, now, now); err != nil {
t.Fatalf("seed legacy rows: %v", err)
}
store, err := NewStore(db, db)
if err != nil {
t.Fatalf("new store: %v", err)
}
if got := store.MigratedFromWorkspace(); got != "ws-new" {
t.Errorf("expected migration to promote ws-new (most recently updated), got %q", got)
}
cfg, err := store.GetConfig(context.Background())
if err != nil || cfg == nil {
t.Fatalf("get post-migration: cfg=%v err=%v", cfg, err)
}
if cfg.UtilityAgentID != "ua-new" || cfg.CommandPrefix != "!kandev" || cfg.PollIntervalSeconds != 60 {
t.Errorf("expected ws-new fields preserved, got %+v", cfg)
}
if cfg.SlackTeamID != "T-new" || cfg.SlackUserID != "U-new" || cfg.LastSeenTS != "200.0" {
t.Errorf("expected ws-new runtime state preserved, got %+v", cfg)
}
}
func TestStore_LegacyEmptyTableMigration(t *testing.T) {
// A deployment that had the legacy table created but never inserted a
// row should still upgrade cleanly without crashing on the empty SELECT.
raw, _ := sql.Open("sqlite3", ":memory:")
raw.SetMaxOpenConns(1)
db := sqlx.NewDb(raw, "sqlite3")
t.Cleanup(func() { _ = db.Close() })
if _, err := db.Exec(`
CREATE TABLE slack_configs (
workspace_id TEXT PRIMARY KEY,
auth_method TEXT NOT NULL,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
)`); err != nil {
t.Fatalf("create empty legacy: %v", err)
}
store, err := NewStore(db, db)
if err != nil {
t.Fatalf("new store on empty legacy: %v", err)
}
if store.MigratedFromWorkspace() != "" {
t.Error("expected no migration when legacy table is empty")
}
cfg, _ := store.GetConfig(context.Background())
if cfg != nil {
t.Errorf("expected no config post empty-legacy upgrade, got %+v", cfg)
}
}