Files
multica/server/internal/integrations/slack/slash_command_test.go
Naiyuan Qing afc7ac4bd0 feat: space rollout — space-first navigation, per-user membership, move-to-space (MUL-4142)
Squashed history of PR #4892 (pr-4784-fix). Makes spaces the primary
navigation and working surface, on the "associations bind at creation
time only" model.

Model
- Issue <-> space is the only enforced ownership (per-space numbering).
  Parent/child and project<->space associations only seed defaults at
  creation; cross-space/child and project-association validations are
  removed.
- Moving an issue renumbers it and records the old identifier in
  issue_identifier_alias; API/CLI lookups and GitHub branch/PR
  auto-linking fall back to the alias, so old identifiers resolve forever.
- Membership drives only the sidebar and personal defaults — never
  access. Anyone can configure any space's member set wholesale
  (PUT /api/spaces/{id}/members); saving an empty set archives the
  space behind a confirm.
- Per-user space order (workspace_space_member.sort_order, fractional):
  drag-sorted sidebar, "my first space" is the personal issue-creation
  default; the workspace default space backs headless creation
  (agents/CLI/Slack) and system placement.

Surfaces
- Sidebar: joined-spaces section (drag reorder, row -> space page,
  per-group persisted collapse), Workspace group with a More menu,
  Settings demoted to a footer icon.
- /space/:key/{issues,projects,autopilots,settings} — space surfaces
  reuse shared page components; a routed /space/new create page
  (replacing the earlier create-space modal), reserved key "NEW" so it
  can never collide with a real space's /space/:key detail page.
- Issue detail moves to /issue/:id (identifier-first, Linear-style; old
  /issues/:id redirects); create dialogs lead with a required space pill.
- Agent runtime brief now carries Space context (id/key/name) through
  the daemon claim -> TaskContextForEnv -> prompt pipeline, with a
  "## Space Context" section and --space on issue create/update in both
  brief renderers, matching Project's existing treatment.
- zh-Hans: Space translated to 空间 across locales and conventions.zh.mdx.

Fixes along the way
- Cache membership judgment gains the space dimension + space_changed
  WS flag.
- Silent skip on default-space lookup during invite acceptance is now a
  hard failure (no space-less members).
- Backfilled space_id into ~28 raw-SQL Go test fixtures across
  internal/handler and cmd/server that predated migration 132's NOT
  NULL cutover.
- Fixed a resolve-loop bug in the IssueDetail identifier wrapper (mount/
  unmount cycle on resolution failure) and gave it its own loading
  skeleton instead of a blank screen while resolving.
- reserved-slugs generator's stale hardcoded doc-comment example synced
  back to /create-space.

Verification
- go build ./..., go vet ./..., go test ./... all clean.
- pnpm typecheck (core/views/web/desktop) clean.
- packages/views: 162 files / 1656 tests passing.
- pnpm generate:reserved-slugs produces no diff.

Follow-ups tracked in docs/follow-ups/space-rollout.md.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-08 20:57:38 +08:00

288 lines
8.4 KiB
Go

package slack
import (
"context"
"errors"
"log/slog"
"strings"
"testing"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/slack-go/slack"
db "github.com/multica-ai/multica/server/pkg/db/generated"
)
// ---- fakes ----
type fakeSlashQueries struct {
inst db.ChannelInstallation
instErr error
binding db.ChannelUserBinding
bindErr error
memberErr error
gotAppID string
}
func (f *fakeSlashQueries) GetChannelInstallationByAppID(_ context.Context, arg db.GetChannelInstallationByAppIDParams) (db.ChannelInstallation, error) {
f.gotAppID = arg.AppID
return f.inst, f.instErr
}
func (f *fakeSlashQueries) GetChannelUserBindingByUserID(_ context.Context, _ db.GetChannelUserBindingByUserIDParams) (db.ChannelUserBinding, error) {
return f.binding, f.bindErr
}
func (f *fakeSlashQueries) GetMemberByUserAndWorkspace(_ context.Context, _ db.GetMemberByUserAndWorkspaceParams) (db.Member, error) {
return db.Member{}, f.memberErr
}
// fakeQuickCreate records the last EnqueueQuickCreateTask call so tests can
// assert the prompt is passed through verbatim and attributed correctly.
type fakeQuickCreate struct {
task db.AgentTaskQueue
err error
calls int
workspaceID pgtype.UUID
requesterID pgtype.UUID
agentID pgtype.UUID
squadID pgtype.UUID
prompt string
}
func (f *fakeQuickCreate) EnqueueQuickCreateTask(_ context.Context, workspaceID, requesterID, agentID, squadID pgtype.UUID, prompt string, _, _, _ pgtype.UUID, _ []pgtype.UUID) (db.AgentTaskQueue, error) {
f.calls++
f.workspaceID = workspaceID
f.requesterID = requesterID
f.agentID = agentID
f.squadID = squadID
f.prompt = prompt
return f.task, f.err
}
func slashTestUUID(b byte) pgtype.UUID {
var u pgtype.UUID
for i := range u.Bytes {
u.Bytes[i] = b
}
u.Valid = true
return u
}
// newTestSlashProcessor builds a processor over fakes and returns it plus a
// pointer to the last ephemeral reply text and the reply count.
func newTestSlashProcessor(q slashQueries, tasks quickCreateEnqueuer, binding bindingMinter) (*SlashCommandProcessor, *string, *int) {
captured := new(string)
count := new(int)
p := &SlashCommandProcessor{
q: q,
tasks: tasks,
binding: binding,
appURL: "https://app.example",
bindingPath: "/slack/bind",
logger: slog.Default(),
}
p.respond = func(_ context.Context, _ string, text string) error {
*count++
*captured = text
return nil
}
return p, captured, count
}
func activeSlashInstallation() db.ChannelInstallation {
return db.ChannelInstallation{
ID: slashTestUUID(1),
WorkspaceID: slashTestUUID(2),
AgentID: slashTestUUID(3),
InstallerUserID: slashTestUUID(4),
Status: "active",
Config: []byte(`{"app_id":"A1","team_id":"T1"}`),
}
}
func issueSlashCmd() slack.SlashCommand {
return slack.SlashCommand{
Command: "/issue",
Text: "Fix login",
APIAppID: "A1",
TeamID: "T1",
UserID: "U1",
ChannelID: "C1",
ResponseURL: "https://hooks.slack.test/response",
}
}
// ---- tests ----
func TestSlashHandle_EnqueuesQuickCreateAndAcks(t *testing.T) {
q := &fakeSlashQueries{
inst: activeSlashInstallation(),
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
}
tasks := &fakeQuickCreate{}
p, captured, count := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if tasks.calls != 1 {
t.Fatalf("expected 1 quick-create enqueue, got %d", tasks.calls)
}
if *count != 1 {
t.Fatalf("expected 1 ephemeral reply, got %d", *count)
}
if *captured != slashQueuedText {
t.Fatalf("expected queued ack, got %q", *captured)
}
if q.gotAppID != "A1" {
t.Errorf("installation lookup used app id %q, want A1", q.gotAppID)
}
if tasks.prompt != "Fix login" {
t.Errorf("quick-create prompt = %q, want Fix login", tasks.prompt)
}
if tasks.workspaceID != slashTestUUID(2) {
t.Errorf("quick-create workspace is not the installation workspace")
}
if tasks.agentID != slashTestUUID(3) {
t.Errorf("quick-create not dispatched to the installation agent")
}
if tasks.requesterID != slashTestUUID(9) {
t.Errorf("quick-create requester is not the bound member")
}
if tasks.squadID.Valid {
t.Errorf("slash-command quick-create must not carry a squad id")
}
}
func TestSlashHandle_MultilinePromptPassedThrough(t *testing.T) {
q := &fakeSlashQueries{
inst: activeSlashInstallation(),
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
}
tasks := &fakeQuickCreate{}
p, _, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
cmd := issueSlashCmd()
cmd.Text = " Title\nline one\nline two "
p.Handle(context.Background(), cmd)
// The whole (trimmed) natural-language text is the prompt — no title/body
// split; the agent authors the well-formed issue from it.
if tasks.prompt != "Title\nline one\nline two" {
t.Errorf("prompt = %q, want the full trimmed text", tasks.prompt)
}
}
func TestSlashHandle_EmptyPromptIsUsage(t *testing.T) {
tasks := &fakeQuickCreate{}
p, captured, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, tasks, &fakeBindingMinter{})
cmd := issueSlashCmd()
cmd.Text = " "
p.Handle(context.Background(), cmd)
if tasks.calls != 0 {
t.Fatalf("empty prompt must not enqueue a task")
}
if *count != 1 || *captured != slashUsageText {
t.Fatalf("expected usage reply, got %q", *captured)
}
}
func TestSlashHandle_UnboundUserGetsLink(t *testing.T) {
q := &fakeSlashQueries{inst: activeSlashInstallation(), bindErr: pgx.ErrNoRows}
tasks := &fakeQuickCreate{}
bind := &fakeBindingMinter{raw: "TOKEN123"}
p, captured, _ := newTestSlashProcessor(q, tasks, bind)
p.Handle(context.Background(), issueSlashCmd())
if tasks.calls != 0 {
t.Fatalf("unbound user must not enqueue a task")
}
if bind.calls != 1 {
t.Fatalf("expected a binding token to be minted, got %d", bind.calls)
}
if !strings.Contains(*captured, "link your account") || !strings.Contains(*captured, "TOKEN123") {
t.Fatalf("reply missing bind link: %q", *captured)
}
}
func TestSlashHandle_NonMemberDropped(t *testing.T) {
q := &fakeSlashQueries{
inst: activeSlashInstallation(),
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
memberErr: pgx.ErrNoRows,
}
tasks := &fakeQuickCreate{}
p, captured, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if tasks.calls != 0 {
t.Fatalf("non-member must not enqueue a task")
}
if *captured != slashNotMemberText {
t.Fatalf("expected not-member reply, got %q", *captured)
}
}
func TestSlashHandle_InactiveInstallation(t *testing.T) {
inst := activeSlashInstallation()
inst.Status = "revoked"
tasks := &fakeQuickCreate{}
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: inst}, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if tasks.calls != 0 || *captured != slashDisabledText {
t.Fatalf("inactive install: calls=%d reply=%q", tasks.calls, *captured)
}
}
func TestSlashHandle_TeamMismatchTreatedAsDisconnected(t *testing.T) {
tasks := &fakeQuickCreate{}
p, captured, _ := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, tasks, &fakeBindingMinter{})
cmd := issueSlashCmd()
cmd.TeamID = "T2" // config team is T1
p.Handle(context.Background(), cmd)
if tasks.calls != 0 || *captured != slashDisabledText {
t.Fatalf("team mismatch: calls=%d reply=%q", tasks.calls, *captured)
}
}
func TestSlashHandle_EnqueueFailureIsInternalError(t *testing.T) {
q := &fakeSlashQueries{
inst: activeSlashInstallation(),
binding: db.ChannelUserBinding{MulticaUserID: slashTestUUID(9)},
}
tasks := &fakeQuickCreate{err: errors.New("agent has no runtime")}
p, captured, _ := newTestSlashProcessor(q, tasks, &fakeBindingMinter{})
p.Handle(context.Background(), issueSlashCmd())
if tasks.calls != 1 {
t.Fatalf("expected the enqueue to be attempted once, got %d", tasks.calls)
}
if *captured != slashInternalErrorText {
t.Fatalf("expected internal-error reply, got %q", *captured)
}
}
func TestSlashHandle_IgnoresOtherCommands(t *testing.T) {
tasks := &fakeQuickCreate{}
p, _, count := newTestSlashProcessor(&fakeSlashQueries{inst: activeSlashInstallation()}, tasks, &fakeBindingMinter{})
cmd := issueSlashCmd()
cmd.Command = "/other"
p.Handle(context.Background(), cmd)
if tasks.calls != 0 || *count != 0 {
t.Fatalf("non-/issue command must be ignored: calls=%d replies=%d", tasks.calls, *count)
}
}