Workflow independence audit, store tests, InstallPackage decomposition

Fix RenderWorkflow() handler (was a stub that never loaded data),
remove dead chat UI from workflow.html, align stage mode naming
with Go constants, add 17 SQLite store tests, decompose 400-line
InstallPackage into 7 phases, add E2E workflow-without-chat test.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-02 22:08:46 +00:00
parent 3cdfdcf943
commit 5279227732
8 changed files with 1221 additions and 416 deletions

View File

@@ -0,0 +1,629 @@
package sqlite
import (
"context"
"encoding/json"
"os"
"testing"
"armature/database"
"armature/models"
"armature/store"
)
func TestMain(m *testing.M) {
os.Setenv("DB_DRIVER", "sqlite")
teardown := database.SetupTestDB()
code := m.Run()
teardown()
os.Exit(code)
}
func resetDB(t *testing.T) {
t.Helper()
database.TruncateAll(t)
SetDB(database.TestDB)
}
// newWF creates a workflow with required defaults filled in.
func newWF(name, slug, createdBy string) *models.Workflow {
return &models.Workflow{
Name: name,
Slug: slug,
EntryMode: "public_link",
CreatedBy: createdBy,
}
}
// ── Workflow CRUD ───────────────────────────────
func TestWorkflowCreate(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Bug Report", "bug-report", userID)
wf.Description = "Report a bug"
wf.IsActive = true
if err := s.Create(ctx, wf); err != nil {
t.Fatalf("Create: %v", err)
}
if wf.ID == "" {
t.Fatal("expected ID to be assigned")
}
if wf.Version != 1 {
t.Fatalf("expected version=1, got %d", wf.Version)
}
}
func TestWorkflowGetByID(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Test WF", "test-wf", userID)
wf.IsActive = true
if err := s.Create(ctx, wf); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := s.GetByID(ctx, wf.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.Name != "Test WF" {
t.Fatalf("expected name 'Test WF', got %q", got.Name)
}
}
func TestWorkflowGetBySlug(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Slug Test", "slug-test", userID)
s.Create(ctx, wf)
got, err := s.GetBySlug(ctx, nil, "slug-test")
if err != nil {
t.Fatalf("GetBySlug: %v", err)
}
if got == nil || got.ID != wf.ID {
t.Fatal("expected to find workflow by slug")
}
got, _ = s.GetBySlug(ctx, nil, "nope")
if got != nil {
t.Fatal("expected nil for non-existent slug")
}
}
func TestWorkflowUpdate(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Original", "original", userID)
s.Create(ctx, wf)
newName := "Updated"
if err := s.Update(ctx, wf.ID, models.WorkflowPatch{Name: &newName}); err != nil {
t.Fatalf("Update: %v", err)
}
got, _ := s.GetByID(ctx, wf.ID)
if got.Name != "Updated" {
t.Fatalf("expected name 'Updated', got %q", got.Name)
}
if got.Version != 2 {
t.Fatalf("expected version=2, got %d", got.Version)
}
}
func TestWorkflowDelete(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Doomed", "doomed", userID)
s.Create(ctx, wf)
if err := s.Delete(ctx, wf.ID); err != nil {
t.Fatalf("Delete: %v", err)
}
got, _ := s.GetByID(ctx, wf.ID)
if got != nil {
t.Fatal("expected nil after delete")
}
}
func TestWorkflowListGlobal(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
s.Create(ctx, newWF("A", "a", userID))
s.Create(ctx, newWF("B", "b", userID))
list, err := s.ListGlobal(ctx)
if err != nil {
t.Fatalf("ListGlobal: %v", err)
}
if len(list) != 2 {
t.Fatalf("expected 2 workflows, got %d", len(list))
}
}
// ── Stage CRUD ──────────────────────────────────
func TestStageCRUD(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Staged", "staged", userID)
s.Create(ctx, wf)
stage1 := &models.WorkflowStage{
WorkflowID: wf.ID,
Ordinal: 0,
Name: "Intake",
StageMode: "form",
FormTemplate: json.RawMessage(`{"fields":[{"key":"name","type":"text","label":"Name"}]}`),
}
if err := s.CreateStage(ctx, stage1); err != nil {
t.Fatalf("CreateStage: %v", err)
}
stage2 := &models.WorkflowStage{
WorkflowID: wf.ID,
Ordinal: 1,
Name: "Review",
StageMode: "review",
}
s.CreateStage(ctx, stage2)
stages, err := s.ListStages(ctx, wf.ID)
if err != nil {
t.Fatalf("ListStages: %v", err)
}
if len(stages) != 2 {
t.Fatalf("expected 2 stages, got %d", len(stages))
}
if stages[0].Name != "Intake" || stages[0].StageMode != "form" {
t.Fatalf("stage 0: got %s/%s", stages[0].Name, stages[0].StageMode)
}
if stages[1].Name != "Review" || stages[1].StageMode != "review" {
t.Fatalf("stage 1: got %s/%s", stages[1].Name, stages[1].StageMode)
}
// Update
stage1.Name = "Updated Intake"
if err := s.UpdateStage(ctx, stage1); err != nil {
t.Fatalf("UpdateStage: %v", err)
}
stages, _ = s.ListStages(ctx, wf.ID)
if stages[0].Name != "Updated Intake" {
t.Fatalf("expected updated name, got %q", stages[0].Name)
}
// Delete
if err := s.DeleteStage(ctx, stage2.ID); err != nil {
t.Fatalf("DeleteStage: %v", err)
}
stages, _ = s.ListStages(ctx, wf.ID)
if len(stages) != 1 {
t.Fatalf("expected 1 stage after delete, got %d", len(stages))
}
}
func TestStageReorder(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Reorder", "reorder", userID)
s.Create(ctx, wf)
s1 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "First", StageMode: "form"}
s2 := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 1, Name: "Second", StageMode: "review"}
s.CreateStage(ctx, s1)
s.CreateStage(ctx, s2)
if err := s.ReorderStages(ctx, wf.ID, []string{s2.ID, s1.ID}); err != nil {
t.Fatalf("ReorderStages: %v", err)
}
stages, _ := s.ListStages(ctx, wf.ID)
if stages[0].Name != "Second" || stages[1].Name != "First" {
t.Fatal("expected reversed order after reorder")
}
}
// ── Instance Lifecycle ──────────────────────────
func TestInstanceLifecycle(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Lifecycle", "lifecycle", userID)
wf.IsActive = true
s.Create(ctx, wf)
stage := &models.WorkflowStage{WorkflowID: wf.ID, Ordinal: 0, Name: "Step 1", StageMode: "form"}
s.CreateStage(ctx, stage)
token := "test-token-123"
inst := &models.WorkflowInstance{
WorkflowID: wf.ID,
WorkflowVersion: 1,
CurrentStage: stage.ID,
Status: "active",
StartedBy: userID,
EntryToken: &token,
StageData: json.RawMessage(`{}`),
Metadata: json.RawMessage(`{}`),
}
if err := s.CreateInstance(ctx, inst); err != nil {
t.Fatalf("CreateInstance: %v", err)
}
// GetInstance
got, err := s.GetInstance(ctx, inst.ID)
if err != nil {
t.Fatalf("GetInstance: %v", err)
}
if got.Status != "active" {
t.Fatalf("expected 'active', got %q", got.Status)
}
// GetInstanceByToken
got, err = s.GetInstanceByToken(ctx, "test-token-123")
if err != nil {
t.Fatalf("GetInstanceByToken: %v", err)
}
if got.ID != inst.ID {
t.Fatal("token lookup returned wrong instance")
}
// AdvanceStage
if err := s.AdvanceStage(ctx, inst.ID, "step-2", json.RawMessage(`{"key":"value"}`)); err != nil {
t.Fatalf("AdvanceStage: %v", err)
}
got, _ = s.GetInstance(ctx, inst.ID)
if got.CurrentStage != "step-2" {
t.Fatalf("expected 'step-2', got %q", got.CurrentStage)
}
// CompleteInstance
if err := s.CompleteInstance(ctx, inst.ID); err != nil {
t.Fatalf("CompleteInstance: %v", err)
}
got, _ = s.GetInstance(ctx, inst.ID)
if got.Status != "completed" {
t.Fatalf("expected 'completed', got %q", got.Status)
}
}
func TestInstanceCancel(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Cancel", "cancel", userID)
s.Create(ctx, wf)
inst := &models.WorkflowInstance{
WorkflowID: wf.ID, WorkflowVersion: 1, CurrentStage: "s1",
Status: "active", StartedBy: userID,
StageData: json.RawMessage(`{}`), Metadata: json.RawMessage(`{}`),
}
s.CreateInstance(ctx, inst)
if err := s.CancelInstance(ctx, inst.ID); err != nil {
t.Fatalf("CancelInstance: %v", err)
}
got, _ := s.GetInstance(ctx, inst.ID)
if got.Status != "cancelled" {
t.Fatalf("expected 'cancelled', got %q", got.Status)
}
}
func TestInstanceStale(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("Stale", "stale", userID)
s.Create(ctx, wf)
inst := &models.WorkflowInstance{
WorkflowID: wf.ID, WorkflowVersion: 1, CurrentStage: "s1",
Status: "active", StartedBy: userID,
StageData: json.RawMessage(`{}`), Metadata: json.RawMessage(`{}`),
}
s.CreateInstance(ctx, inst)
if err := s.MarkInstanceStale(ctx, inst.ID); err != nil {
t.Fatalf("MarkInstanceStale: %v", err)
}
got, _ := s.GetInstance(ctx, inst.ID)
if got.Status != "stale" {
t.Fatalf("expected 'stale', got %q", got.Status)
}
}
func TestListInstances(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
wf := newWF("List", "list", userID)
s.Create(ctx, wf)
for i := 0; i < 3; i++ {
inst := &models.WorkflowInstance{
WorkflowID: wf.ID, WorkflowVersion: 1, CurrentStage: "s1",
Status: "active", StartedBy: userID,
StageData: json.RawMessage(`{}`), Metadata: json.RawMessage(`{}`),
}
s.CreateInstance(ctx, inst)
}
list, err := s.ListInstances(ctx, wf.ID, "", store.ListOptions{Limit: 10})
if err != nil {
t.Fatalf("ListInstances: %v", err)
}
if len(list) != 3 {
t.Fatalf("expected 3, got %d", len(list))
}
list, _ = s.ListInstances(ctx, wf.ID, "completed", store.ListOptions{Limit: 10})
if len(list) != 0 {
t.Fatalf("expected 0 completed, got %d", len(list))
}
}
// ── Team-scoped ─────────────────────────────────
func TestWorkflowTeamScope(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewWorkflowStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "wfuser", "wfuser@test.com")
teamID := database.SeedTestTeam(t, "TestTeam", userID)
wf := newWF("Team WF", "team-wf", userID)
wf.TeamID = &teamID
s.Create(ctx, wf)
got, err := s.GetBySlug(ctx, &teamID, "team-wf")
if err != nil || got == nil {
t.Fatalf("GetBySlug(team): %v", err)
}
if got.ID != wf.ID {
t.Fatal("wrong workflow")
}
list, _ := s.ListForTeam(ctx, teamID)
if len(list) != 1 {
t.Fatalf("expected 1 team workflow, got %d", len(list))
}
global, _ := s.ListGlobal(ctx)
if len(global) != 0 {
t.Fatalf("expected 0 global workflows, got %d", len(global))
}
}
// ── API Token Store ─────────────────────────────
func TestAPITokenCRUD(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewAPITokenStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "tokenuser", "tokenuser@test.com")
tok := &models.APIToken{
UserID: userID,
Name: "CI Token",
TokenHash: "sha256_abc123",
Permissions: []string{"extension.use"},
}
if err := s.Create(ctx, tok); err != nil {
t.Fatalf("Create: %v", err)
}
if tok.ID == "" {
t.Fatal("expected ID")
}
// ListForUser
list, err := s.ListForUser(ctx, userID)
if err != nil {
t.Fatalf("ListForUser: %v", err)
}
if len(list) != 1 {
t.Fatalf("expected 1 token, got %d", len(list))
}
if list[0].Name != "CI Token" {
t.Fatalf("expected 'CI Token', got %q", list[0].Name)
}
// GetByHash
got, err := s.GetByHash(ctx, "sha256_abc123")
if err != nil {
t.Fatalf("GetByHash: %v", err)
}
if got.ID != tok.ID {
t.Fatal("hash lookup returned wrong token")
}
// Revoke
if _, err := s.Revoke(ctx, tok.ID, userID); err != nil {
t.Fatalf("Revoke: %v", err)
}
list, _ = s.ListForUser(ctx, userID)
if len(list) != 0 {
t.Fatalf("expected 0 tokens after revoke, got %d", len(list))
}
}
// ── User Store ──────────────────────────────────
func TestUserGetByID(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewUserStore()
ctx := context.Background()
u := &models.User{
Username: "testuser",
Email: "test@example.com",
PasswordHash: "$2a$10$dummy",
DisplayName: "Test User",
IsActive: true,
}
if err := s.Create(ctx, u); err != nil {
t.Fatalf("Create: %v", err)
}
got, err := s.GetByID(ctx, u.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.Username != "testuser" {
t.Fatalf("expected 'testuser', got %q", got.Username)
}
if got.DisplayName != "Test User" {
t.Fatalf("expected 'Test User', got %q", got.DisplayName)
}
}
func TestUserGetByLogin(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewUserStore()
ctx := context.Background()
u := &models.User{
Username: "logintest",
Email: "login@test.com",
PasswordHash: "$2a$10$dummy",
IsActive: true,
}
s.Create(ctx, u)
// By username
got, err := s.GetByLogin(ctx, "logintest")
if err != nil {
t.Fatalf("GetByLogin(username): %v", err)
}
if got.ID != u.ID {
t.Fatal("wrong user by username")
}
// By email
got, err = s.GetByLogin(ctx, "login@test.com")
if err != nil {
t.Fatalf("GetByLogin(email): %v", err)
}
if got.ID != u.ID {
t.Fatal("wrong user by email")
}
// Case insensitive
got, _ = s.GetByLogin(ctx, "LOGINTEST")
if got == nil || got.ID != u.ID {
t.Fatal("expected case-insensitive match")
}
}
// ── Group Store ─────────────────────────────────
func TestGroupCRUD(t *testing.T) {
database.RequireTestDB(t)
resetDB(t)
s := NewGroupStore()
ctx := context.Background()
userID := database.SeedTestUser(t, "grpuser", "grpuser@test.com")
g := &models.Group{
Name: "Editors",
Description: "Can edit content",
Scope: "global",
CreatedBy: &userID,
Permissions: []string{"extension.use", "workflow.create"},
}
if err := s.Create(ctx, g); err != nil {
t.Fatalf("Create: %v", err)
}
// GetByID
got, err := s.GetByID(ctx, g.ID)
if err != nil {
t.Fatalf("GetByID: %v", err)
}
if got.Name != "Editors" {
t.Fatalf("expected 'Editors', got %q", got.Name)
}
// ListAll (includes system groups from TruncateAll re-seed + our new group)
list, err := s.ListAll(ctx)
if err != nil {
t.Fatalf("ListAll: %v", err)
}
if len(list) < 3 { // Everyone + Admins + Editors
t.Fatalf("expected ≥3 groups, got %d", len(list))
}
// AddMember / ListMembers
if err := s.AddMember(ctx, g.ID, userID, userID); err != nil {
t.Fatalf("AddMember: %v", err)
}
members, err := s.ListMembers(ctx, g.ID)
if err != nil {
t.Fatalf("ListMembers: %v", err)
}
if len(members) != 1 {
t.Fatalf("expected 1 member, got %d", len(members))
}
// RemoveMember
if err := s.RemoveMember(ctx, g.ID, userID); err != nil {
t.Fatalf("RemoveMember: %v", err)
}
members, _ = s.ListMembers(ctx, g.ID)
if len(members) != 0 {
t.Fatalf("expected 0 members after remove, got %d", len(members))
}
}