Adds closed_by_session tracking for entity CV building per Gas Town decision 009-session-events-architecture.md. Changes: - Add ClosedBySession field to Issue struct - Add closed_by_session column to issues table (migration 034) - Add --session flag to bd close command - Support CLAUDE_SESSION_ID env var as fallback - Add --session flag to bd update for status=closed - Display closed_by_session in bd show output - Update Storage interface to include session parameter in CloseIssue 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> Executed-By: beads/crew/dave Rig: beads Role: crew
193 lines
5.7 KiB
Go
193 lines
5.7 KiB
Go
package sqlite
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/steveyegge/beads/internal/types"
|
|
)
|
|
|
|
// testEnv provides a test environment with common setup and helpers.
|
|
// Use newTestEnv(t) to create a test environment with automatic cleanup.
|
|
type testEnv struct {
|
|
t *testing.T
|
|
Store *SQLiteStorage
|
|
Ctx context.Context
|
|
}
|
|
|
|
// newTestEnv creates a new test environment with a configured store.
|
|
// The store is automatically cleaned up when the test completes.
|
|
func newTestEnv(t *testing.T) *testEnv {
|
|
t.Helper()
|
|
store := newTestStore(t, "")
|
|
return &testEnv{
|
|
t: t,
|
|
Store: store,
|
|
Ctx: context.Background(),
|
|
}
|
|
}
|
|
|
|
// CreateIssue creates a test issue with the given title and defaults.
|
|
// Returns the created issue with ID populated.
|
|
func (e *testEnv) CreateIssue(title string) *types.Issue {
|
|
e.t.Helper()
|
|
return e.CreateIssueWith(title, types.StatusOpen, 2, types.TypeTask)
|
|
}
|
|
|
|
// CreateIssueWith creates a test issue with specified attributes.
|
|
func (e *testEnv) CreateIssueWith(title string, status types.Status, priority int, issueType types.IssueType) *types.Issue {
|
|
e.t.Helper()
|
|
issue := &types.Issue{
|
|
Title: title,
|
|
Status: status,
|
|
Priority: priority,
|
|
IssueType: issueType,
|
|
}
|
|
if err := e.Store.CreateIssue(e.Ctx, issue, "test-user"); err != nil {
|
|
e.t.Fatalf("CreateIssue(%q) failed: %v", title, err)
|
|
}
|
|
return issue
|
|
}
|
|
|
|
// CreateIssueWithAssignee creates a test issue with an assignee.
|
|
func (e *testEnv) CreateIssueWithAssignee(title, assignee string) *types.Issue {
|
|
e.t.Helper()
|
|
issue := &types.Issue{
|
|
Title: title,
|
|
Status: types.StatusOpen,
|
|
Priority: 2,
|
|
IssueType: types.TypeTask,
|
|
Assignee: assignee,
|
|
}
|
|
if err := e.Store.CreateIssue(e.Ctx, issue, "test-user"); err != nil {
|
|
e.t.Fatalf("CreateIssue(%q) failed: %v", title, err)
|
|
}
|
|
return issue
|
|
}
|
|
|
|
// CreateEpic creates an epic issue.
|
|
func (e *testEnv) CreateEpic(title string) *types.Issue {
|
|
e.t.Helper()
|
|
return e.CreateIssueWith(title, types.StatusOpen, 1, types.TypeEpic)
|
|
}
|
|
|
|
// CreateBug creates a bug issue.
|
|
func (e *testEnv) CreateBug(title string, priority int) *types.Issue {
|
|
e.t.Helper()
|
|
return e.CreateIssueWith(title, types.StatusOpen, priority, types.TypeBug)
|
|
}
|
|
|
|
// AddDep adds a blocking dependency (issue depends on dependsOn).
|
|
func (e *testEnv) AddDep(issue, dependsOn *types.Issue) {
|
|
e.t.Helper()
|
|
e.AddDepType(issue, dependsOn, types.DepBlocks)
|
|
}
|
|
|
|
// AddDepType adds a dependency with the specified type.
|
|
func (e *testEnv) AddDepType(issue, dependsOn *types.Issue, depType types.DependencyType) {
|
|
e.t.Helper()
|
|
dep := &types.Dependency{
|
|
IssueID: issue.ID,
|
|
DependsOnID: dependsOn.ID,
|
|
Type: depType,
|
|
}
|
|
if err := e.Store.AddDependency(e.Ctx, dep, "test-user"); err != nil {
|
|
e.t.Fatalf("AddDependency(%s -> %s) failed: %v", issue.ID, dependsOn.ID, err)
|
|
}
|
|
}
|
|
|
|
// AddParentChild adds a parent-child dependency (child belongs to parent).
|
|
func (e *testEnv) AddParentChild(child, parent *types.Issue) {
|
|
e.t.Helper()
|
|
e.AddDepType(child, parent, types.DepParentChild)
|
|
}
|
|
|
|
// Close closes the issue with the given reason.
|
|
func (e *testEnv) Close(issue *types.Issue, reason string) {
|
|
e.t.Helper()
|
|
if err := e.Store.CloseIssue(e.Ctx, issue.ID, reason, "test-user", ""); err != nil {
|
|
e.t.Fatalf("CloseIssue(%s) failed: %v", issue.ID, err)
|
|
}
|
|
}
|
|
|
|
// GetReadyWork gets ready work with the given filter.
|
|
func (e *testEnv) GetReadyWork(filter types.WorkFilter) []*types.Issue {
|
|
e.t.Helper()
|
|
ready, err := e.Store.GetReadyWork(e.Ctx, filter)
|
|
if err != nil {
|
|
e.t.Fatalf("GetReadyWork failed: %v", err)
|
|
}
|
|
return ready
|
|
}
|
|
|
|
// GetReadyIDs returns a map of issue IDs that are ready (open status).
|
|
func (e *testEnv) GetReadyIDs() map[string]bool {
|
|
e.t.Helper()
|
|
ready := e.GetReadyWork(types.WorkFilter{Status: types.StatusOpen})
|
|
ids := make(map[string]bool)
|
|
for _, issue := range ready {
|
|
ids[issue.ID] = true
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// AssertReady asserts that the issue is in the ready work list.
|
|
func (e *testEnv) AssertReady(issue *types.Issue) {
|
|
e.t.Helper()
|
|
ids := e.GetReadyIDs()
|
|
if !ids[issue.ID] {
|
|
e.t.Errorf("expected %s (%s) to be ready, but it was blocked", issue.ID, issue.Title)
|
|
}
|
|
}
|
|
|
|
// AssertBlocked asserts that the issue is NOT in the ready work list.
|
|
func (e *testEnv) AssertBlocked(issue *types.Issue) {
|
|
e.t.Helper()
|
|
ids := e.GetReadyIDs()
|
|
if ids[issue.ID] {
|
|
e.t.Errorf("expected %s (%s) to be blocked, but it was ready", issue.ID, issue.Title)
|
|
}
|
|
}
|
|
|
|
// newTestStore creates a SQLiteStorage with issue_prefix configured (bd-166)
|
|
// This prevents "database not initialized" errors in tests
|
|
//
|
|
// Test Isolation Pattern (bd-2e80):
|
|
// By default, uses "file::memory:?mode=memory&cache=private" for proper test isolation.
|
|
// The standard ":memory:" creates a SHARED database across all tests in the same process,
|
|
// which can cause test interference and flaky behavior. The private mode ensures each
|
|
// test gets its own isolated in-memory database.
|
|
//
|
|
// To override (e.g., for file-based tests), pass a custom dbPath:
|
|
// - For temp files: t.TempDir()+"/test.db"
|
|
// - For shared memory (not recommended): ":memory:"
|
|
func newTestStore(t *testing.T, dbPath string) *SQLiteStorage {
|
|
t.Helper()
|
|
|
|
// Default to temp file for test isolation
|
|
// File-based databases are more reliable than in-memory for connection pool scenarios
|
|
if dbPath == "" {
|
|
dbPath = t.TempDir() + "/test.db"
|
|
}
|
|
|
|
ctx := context.Background()
|
|
store, err := New(ctx, dbPath)
|
|
if err != nil {
|
|
t.Fatalf("Failed to create test database: %v", err)
|
|
}
|
|
|
|
t.Cleanup(func() {
|
|
if cerr := store.Close(); cerr != nil {
|
|
t.Fatalf("Failed to close test database: %v", cerr)
|
|
}
|
|
})
|
|
|
|
// CRITICAL (bd-166): Set issue_prefix to prevent "database not initialized" errors
|
|
if err := store.SetConfig(ctx, "issue_prefix", "bd"); err != nil {
|
|
_ = store.Close()
|
|
t.Fatalf("Failed to set issue_prefix: %v", err)
|
|
}
|
|
|
|
return store
|
|
}
|