mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
757 lines
20 KiB
Go
757 lines
20 KiB
Go
package runtime
|
|
|
|
import (
|
|
"bytes"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/boolean-maybe/tiki/config"
|
|
"github.com/boolean-maybe/tiki/service"
|
|
"github.com/boolean-maybe/tiki/store"
|
|
"github.com/boolean-maybe/tiki/task"
|
|
"github.com/boolean-maybe/tiki/workflow"
|
|
)
|
|
|
|
func setupRunnerTest(t *testing.T) store.Store {
|
|
t.Helper()
|
|
config.ResetStatusRegistry([]workflow.StatusDef{
|
|
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
|
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
|
{Key: "inProgress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
|
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
|
})
|
|
|
|
s := store.NewInMemoryStore()
|
|
_ = s.CreateTask(&task.Task{ID: "TIKI-AAA001", Title: "Build API", Status: "ready", Priority: 1})
|
|
_ = s.CreateTask(&task.Task{ID: "TIKI-BBB002", Title: "Write Docs", Status: "done", Priority: 2})
|
|
return s
|
|
}
|
|
|
|
// gateFor wraps a store in a bare gate (no field validators) for tests.
|
|
func gateFor(s store.Store) *service.TaskMutationGate {
|
|
g := service.NewTaskMutationGate()
|
|
g.SetStore(s)
|
|
return g
|
|
}
|
|
|
|
func TestRunSelectQuerySuccess(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, `select id, title where status = "ready"`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "TIKI-AAA001") {
|
|
t.Errorf("expected TIKI-AAA001 in output:\n%s", out)
|
|
}
|
|
if strings.Contains(out, "TIKI-BBB002") {
|
|
t.Errorf("TIKI-BBB002 should be filtered out:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQueryBareSelect(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, "select", &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
// bare select returns all tasks with all fields
|
|
if !strings.Contains(out, "TIKI-AAA001") || !strings.Contains(out, "TIKI-BBB002") {
|
|
t.Errorf("bare select should return all tasks:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQuerySemicolon(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, "select id, title;", &buf)
|
|
if err != nil {
|
|
t.Fatalf("trailing semicolon should be accepted: %v", err)
|
|
}
|
|
|
|
if !strings.Contains(buf.String(), "TIKI-AAA001") {
|
|
t.Errorf("semicolon query should produce results:\n%s", buf.String())
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQueryParseError(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, "select from where", &buf)
|
|
if err == nil {
|
|
t.Fatal("expected parse error")
|
|
}
|
|
if !strings.Contains(err.Error(), "parse") {
|
|
t.Errorf("error should mention parse: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQueryRejectsNonSelect(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
tests := []struct {
|
|
name string
|
|
query string
|
|
}{
|
|
{"rejects create", `create title="via legacy"`},
|
|
{"rejects update", `update where id = "TIKI-AAA001" set title="x"`},
|
|
{"rejects delete", `delete where id = "TIKI-AAA001"`},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, tt.query, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for non-SELECT statement via RunSelectQuery")
|
|
}
|
|
if !strings.Contains(err.Error(), "only supports SELECT") {
|
|
t.Errorf("expected 'only supports SELECT' error, got: %v", err)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQueryEmptyQuery(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, "", &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty query")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty") {
|
|
t.Errorf("error should mention empty: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQuerySemicolonOnly(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, ";", &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for semicolon-only query")
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQueryUserFunction(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
// InMemoryStore returns "memory-user"
|
|
_ = s.CreateTask(&task.Task{ID: "TIKI-CCC003", Title: "My Task", Status: "ready", Assignee: "memory-user"})
|
|
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, `select id where assignee = user()`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "TIKI-CCC003") {
|
|
t.Errorf("user() should resolve to memory-user:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQueryWhitespaceOnly(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, " ", &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for whitespace-only query")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty") {
|
|
t.Errorf("error should mention empty: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQueryWithOrderBy(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, `select id, title order by priority`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "TIKI-AAA001") || !strings.Contains(out, "TIKI-BBB002") {
|
|
t.Errorf("order by query should return all tasks:\n%s", out)
|
|
}
|
|
}
|
|
|
|
// --- UPDATE via runner ---
|
|
|
|
func TestRunQueryUpdatePersists(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `update where id = "TIKI-AAA001" set title="Updated API"`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
updated := s.GetTask("TIKI-AAA001")
|
|
if updated == nil {
|
|
t.Fatal("task not found after update")
|
|
return
|
|
}
|
|
if updated.Title != "Updated API" {
|
|
t.Errorf("expected title 'Updated API', got %q", updated.Title)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryUpdateSummarySuccess(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `update where status = "ready" set priority=5`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "updated 1 tasks") {
|
|
t.Errorf("expected 'updated 1 tasks' in output, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryUpdateZeroMatches(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `update where id = "NONEXISTENT" set title="x"`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "updated 0 tasks") {
|
|
t.Errorf("expected 'updated 0 tasks' in output, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryUpdateListArithmeticE2E(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
// set up a task with tags
|
|
_ = s.CreateTask(&task.Task{ID: "TIKI-TAG001", Title: "Tagged", Status: "ready", Tags: []string{"old"}})
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `update where id = "TIKI-TAG001" set tags=tags+"new"`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
updated := s.GetTask("TIKI-TAG001")
|
|
if len(updated.Tags) != 2 || updated.Tags[0] != "old" || updated.Tags[1] != "new" {
|
|
t.Errorf("expected tags [old new], got %v", updated.Tags)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryUpdatePartialFailure(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
// create a second ready task so we update multiple
|
|
_ = s.CreateTask(&task.Task{ID: "TIKI-CCC003", Title: "Third", Status: "ready", Priority: 3})
|
|
|
|
// delete the first task's file to cause UpdateTask to fail on it
|
|
// (InMemoryStore won't fail on UpdateTask, so we test with a wrapper)
|
|
fs := &failingUpdateStore{Store: s, failID: "TIKI-AAA001"}
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(fs), `update where status = "ready" set priority=5`, &buf)
|
|
|
|
out := buf.String()
|
|
if err == nil {
|
|
t.Fatal("expected error for partial failure")
|
|
}
|
|
if !strings.Contains(out, "failed") {
|
|
t.Errorf("expected 'failed' in output, got: %s", out)
|
|
}
|
|
if !strings.Contains(err.Error(), "partially failed") {
|
|
t.Errorf("expected 'partially failed' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// failingUpdateStore wraps a Store and fails UpdateTask for a specific task ID.
|
|
type failingUpdateStore struct {
|
|
store.Store
|
|
failID string
|
|
}
|
|
|
|
func (f *failingUpdateStore) UpdateTask(t *task.Task) error {
|
|
if t.ID == f.failID {
|
|
return fmt.Errorf("simulated update failure for %s", t.ID)
|
|
}
|
|
return f.Store.UpdateTask(t)
|
|
}
|
|
|
|
// failingUserStore wraps a Store and makes GetCurrentUser fail.
|
|
type failingUserStore struct {
|
|
store.Store
|
|
}
|
|
|
|
func (f *failingUserStore) GetCurrentUser() (string, string, error) {
|
|
return "", "", fmt.Errorf("simulated user resolution failure")
|
|
}
|
|
|
|
func TestRunQueryResolveUserError(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
fs := &failingUserStore{Store: s}
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(fs), "select", &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for user resolution failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "resolve current user") {
|
|
t.Errorf("expected 'resolve current user' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryResolveUserErrorUpdate(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
fs := &failingUserStore{Store: s}
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(fs), `update where id = "TIKI-AAA001" set title="x"`, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for user resolution failure on update")
|
|
}
|
|
if !strings.Contains(err.Error(), "resolve current user") {
|
|
t.Errorf("expected 'resolve current user' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryExecuteError(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
// call() is rejected during semantic validation in RunQuery.
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `select where call("echo") = "x"`, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected semantic validation error")
|
|
}
|
|
if !strings.Contains(err.Error(), "call() is not supported yet") {
|
|
t.Errorf("expected call() semantic validation error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryUpdateInvalidPointsE2E(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `update where id = "TIKI-AAA001" set points=999`, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for invalid points")
|
|
}
|
|
if !strings.Contains(err.Error(), "points value out of range") {
|
|
t.Errorf("expected points range error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- CREATE via runner ---
|
|
|
|
func TestRunQueryCreatePersists(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `create title="New Task" status="ready" priority=1`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "created TIKI-") {
|
|
t.Fatalf("expected 'created TIKI-' in output, got: %s", out)
|
|
}
|
|
|
|
// verify task exists in store
|
|
allTasks := s.GetAllTasks()
|
|
var found *task.Task
|
|
for _, tk := range allTasks {
|
|
if tk.Title == "New Task" {
|
|
found = tk
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("created task not found in store")
|
|
return
|
|
}
|
|
if !strings.HasPrefix(found.ID, "TIKI-") || len(found.ID) != 11 {
|
|
t.Errorf("ID = %q, want TIKI-XXXXXX format (11 chars)", found.ID)
|
|
}
|
|
if found.Priority != 1 {
|
|
t.Errorf("priority = %d, want 1", found.Priority)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryCreateMissingTitle(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
// use BuildGate (with field validators) to catch empty title
|
|
g := service.BuildGate()
|
|
g.SetStore(s)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(g, `create priority=1 status="ready"`, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for missing title")
|
|
}
|
|
if !strings.Contains(err.Error(), "title") {
|
|
t.Errorf("expected title error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryCreateTemplateDefaults(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `create title="Templated" tags=tags+["extra"]`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
allTasks := s.GetAllTasks()
|
|
var found *task.Task
|
|
for _, tk := range allTasks {
|
|
if tk.Title == "Templated" {
|
|
found = tk
|
|
break
|
|
}
|
|
}
|
|
if found == nil {
|
|
t.Fatal("created task not found in store")
|
|
return
|
|
}
|
|
// InMemoryStore template has tags=["idea"], so result should be ["idea", "extra"]
|
|
if len(found.Tags) != 2 || found.Tags[0] != "idea" || found.Tags[1] != "extra" {
|
|
t.Errorf("tags = %v, want [idea extra]", found.Tags)
|
|
}
|
|
// priority should be template default (3 = medium)
|
|
if found.Priority != 3 {
|
|
t.Errorf("priority = %d, want 3 (template default)", found.Priority)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryCreateTemplateFailure(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
fs := &failingTemplateStore{Store: s}
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(fs), `create title="test"`, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for template failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "create template") {
|
|
t.Errorf("expected 'create template' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// failingTemplateStore wraps a Store and fails NewTaskTemplate.
|
|
type failingTemplateStore struct {
|
|
store.Store
|
|
}
|
|
|
|
func (f *failingTemplateStore) NewTaskTemplate() (*task.Task, error) {
|
|
return nil, fmt.Errorf("simulated template failure")
|
|
}
|
|
|
|
func TestRunQueryCreateNilTemplate(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
fs := &nilTemplateStore{Store: s}
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(fs), `create title="test"`, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for nil template")
|
|
}
|
|
if !strings.Contains(err.Error(), "nil template") {
|
|
t.Errorf("expected 'nil template' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// nilTemplateStore wraps a Store and returns (nil, nil) from NewTaskTemplate.
|
|
type nilTemplateStore struct {
|
|
store.Store
|
|
}
|
|
|
|
func (f *nilTemplateStore) NewTaskTemplate() (*task.Task, error) {
|
|
return nil, nil
|
|
}
|
|
|
|
func TestRunQueryCreateTaskFailure(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
fs := &failingCreateStore{Store: s}
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(fs), `create title="test"`, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for CreateTask failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "create task") {
|
|
t.Errorf("expected 'create task' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// failingCreateStore wraps a Store and fails CreateTask.
|
|
type failingCreateStore struct {
|
|
store.Store
|
|
}
|
|
|
|
func (f *failingCreateStore) CreateTask(t *task.Task) error {
|
|
return fmt.Errorf("simulated create failure")
|
|
}
|
|
|
|
// --- DELETE via runner ---
|
|
|
|
func TestRunQueryDeletePersists(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `delete where id = "TIKI-AAA001"`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "deleted 1 tasks") {
|
|
t.Errorf("expected 'deleted 1 tasks' in output, got: %s", out)
|
|
}
|
|
if s.GetTask("TIKI-AAA001") != nil {
|
|
t.Error("task should be deleted from store")
|
|
}
|
|
}
|
|
|
|
func TestRunQueryDeleteZeroMatches(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `delete where id = "NONEXISTENT"`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "deleted 0 tasks") {
|
|
t.Errorf("expected 'deleted 0 tasks' in output, got: %s", out)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryDeletePartialFailure(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
// add a second ready task so we match multiple
|
|
_ = s.CreateTask(&task.Task{ID: "TIKI-CCC003", Title: "Third", Status: "ready", Priority: 3})
|
|
|
|
fs := &failingDeleteStore{Store: s, failID: "TIKI-AAA001"}
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(fs), `delete where status = "ready"`, &buf)
|
|
|
|
out := buf.String()
|
|
if err == nil {
|
|
t.Fatal("expected error for partial failure")
|
|
}
|
|
if !strings.Contains(out, "failed") {
|
|
t.Errorf("expected 'failed' in output, got: %s", out)
|
|
}
|
|
if !strings.Contains(err.Error(), "partially failed") {
|
|
t.Errorf("expected 'partially failed' in error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// failingDeleteStore wraps a Store and silently no-ops DeleteTask for a specific ID.
|
|
type failingDeleteStore struct {
|
|
store.Store
|
|
failID string
|
|
}
|
|
|
|
func (f *failingDeleteStore) DeleteTask(id string) {
|
|
if id == f.failID {
|
|
return // simulate silent failure
|
|
}
|
|
f.Store.DeleteTask(id)
|
|
}
|
|
|
|
func TestRunQueryEmptyQuery(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), "", &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for empty query")
|
|
}
|
|
if !strings.Contains(err.Error(), "empty") {
|
|
t.Errorf("expected 'empty' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryParseError(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), "select from where", &buf)
|
|
if err == nil {
|
|
t.Fatal("expected parse error")
|
|
}
|
|
if !strings.Contains(err.Error(), "parse") {
|
|
t.Errorf("expected 'parse' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQueryResolveUserError(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
fs := &failingUserStore{Store: s}
|
|
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(fs, "select", &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for user resolution failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "resolve current user") {
|
|
t.Errorf("expected 'resolve current user' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// --- UPDATE via RunQuery (covers the result.Update branch in RunQuery) ---
|
|
|
|
func TestRunQuerySelectViaRunQuery(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `select id where status = "ready"`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if !strings.Contains(buf.String(), "TIKI-AAA001") {
|
|
t.Errorf("expected TIKI-AAA001 in output:\n%s", buf.String())
|
|
}
|
|
}
|
|
|
|
// --- DELETE partial failure via silent DeleteTask no-op detection ---
|
|
|
|
func TestRunQueryDeleteSilentFailure(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
// failingDeleteStore silently ignores delete for failID
|
|
fs := &failingDeleteStore{Store: s, failID: "TIKI-AAA001"}
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(fs), `delete where id = "TIKI-AAA001"`, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error for silent delete failure")
|
|
}
|
|
if !strings.Contains(err.Error(), "partially failed") {
|
|
t.Errorf("expected 'partially failed' error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestRunSelectQueryExecuteError(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
// call() is rejected during semantic validation in RunSelectQuery.
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, `select where call("echo") = "x"`, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected semantic validation error")
|
|
}
|
|
if !strings.Contains(err.Error(), "call() is not supported yet") {
|
|
t.Errorf("expected call() semantic validation error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
// failingDeleteTaskStore wraps a Store and makes DeleteTask error via the gate.
|
|
type failingDeleteTaskStore struct {
|
|
store.Store
|
|
failID string
|
|
}
|
|
|
|
func (f *failingDeleteTaskStore) DeleteTask(id string) {
|
|
if id != f.failID {
|
|
f.Store.DeleteTask(id)
|
|
}
|
|
// for failID: silently no-op
|
|
}
|
|
|
|
func TestRunQueryDeleteGateError(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
// use a gate with a validator that rejects the delete
|
|
g := service.NewTaskMutationGate()
|
|
fds := &failingDeleteTaskStore{Store: s, failID: "TIKI-AAA001"}
|
|
g.SetStore(fds)
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(g, `delete where id = "TIKI-AAA001"`, &buf)
|
|
// the store silently fails to delete, so persistDelete detects task still exists
|
|
if err == nil {
|
|
t.Fatal("expected error for delete gate failure")
|
|
}
|
|
}
|
|
|
|
func TestRunQueryUserFunction(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
// select where assignee = user() — exercises the user() closure (line 32)
|
|
var buf bytes.Buffer
|
|
err := RunSelectQuery(s, `select where assignee = user()`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestRunQueryUserFunctionViaRunQuery exercises the user() closure inside RunQuery
|
|
// (not RunSelectQuery). The closure at line 32 captures the resolved user name.
|
|
func TestRunQueryUserFunctionViaRunQuery(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
// InMemoryStore.GetCurrentUser returns "memory-user"
|
|
_ = s.CreateTask(&task.Task{ID: "TIKI-CCC003", Title: "Owned", Status: "ready", Assignee: "memory-user"})
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(gateFor(s), `select id where assignee = user()`, &buf)
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
|
|
out := buf.String()
|
|
if !strings.Contains(out, "TIKI-CCC003") {
|
|
t.Errorf("expected TIKI-CCC003 in output:\n%s", out)
|
|
}
|
|
// tasks without the matching assignee should be filtered out
|
|
if strings.Contains(out, "TIKI-AAA001") {
|
|
t.Errorf("TIKI-AAA001 should be filtered out:\n%s", out)
|
|
}
|
|
}
|
|
|
|
func TestRunQueryDeleteValidatorRejection(t *testing.T) {
|
|
s := setupRunnerTest(t)
|
|
|
|
g := service.NewTaskMutationGate()
|
|
g.SetStore(s)
|
|
g.OnDelete(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
|
return &service.Rejection{Reason: "deletes forbidden"}
|
|
})
|
|
|
|
var buf bytes.Buffer
|
|
err := RunQuery(g, `delete where id = "TIKI-AAA001"`, &buf)
|
|
if err == nil {
|
|
t.Fatal("expected error when delete is rejected by validator")
|
|
}
|
|
if !strings.Contains(err.Error(), "partially failed") {
|
|
t.Errorf("expected 'partially failed' error, got: %v", err)
|
|
}
|
|
// task should still exist
|
|
if s.GetTask("TIKI-AAA001") == nil {
|
|
t.Error("task should not have been deleted")
|
|
}
|
|
}
|