Files
viettranx ee328d4266 refactor: consolidate hooks table migration and drop deprecated agent_id column
- Consolidated PG migration 054: agent_hooks rename + junction table + deprecated column drop
- Updated SQLite schema v19 with final consolidated migration
- Removed obsolete schema rebuild files (v21, v23)
- Updated Go store layer (pg/hooks.go, sqlitestore/hooks.go)
- Updated integration tests to use new table names (hooks, hook_agents)
- Updated TypeScript protocol, UI components, and i18n strings
- Updated gateway methods to reflect schema changes

Tables: agent_hooks → hooks, agent_hook_agents → hook_agents
2026-04-16 14:17:48 +07:00

329 lines
9.2 KiB
Go

package builtin
import (
"context"
"testing"
"github.com/google/uuid"
"github.com/nextlevelbuilder/goclaw/internal/config"
"github.com/nextlevelbuilder/goclaw/internal/hooks"
)
// fakeHookStore is an in-memory HookStore just rich enough to test seed
// reconciliation. Implements only the methods the seeder touches (Create,
// GetByID, Update); others return zero values / nil.
type fakeHookStore struct {
rows map[uuid.UUID]*hooks.HookConfig
}
func newFakeHookStore() *fakeHookStore {
return &fakeHookStore{rows: map[uuid.UUID]*hooks.HookConfig{}}
}
func (f *fakeHookStore) Create(_ context.Context, cfg hooks.HookConfig) (uuid.UUID, error) {
if cfg.ID == uuid.Nil {
cfg.ID = uuid.Must(uuid.NewV7())
}
if _, exists := f.rows[cfg.ID]; exists {
// Mimic DB primary-key conflict to catch non-idempotent seeds.
return uuid.Nil, errDuplicate
}
c := cfg
f.rows[cfg.ID] = &c
return cfg.ID, nil
}
func (f *fakeHookStore) GetByID(_ context.Context, id uuid.UUID) (*hooks.HookConfig, error) {
if r, ok := f.rows[id]; ok {
c := *r
return &c, nil
}
return nil, nil
}
func (f *fakeHookStore) List(context.Context, hooks.ListFilter) ([]hooks.HookConfig, error) {
return nil, nil
}
func (f *fakeHookStore) Update(_ context.Context, id uuid.UUID, updates map[string]any) error {
r, ok := f.rows[id]
if !ok {
return errNotFound
}
for k, v := range updates {
switch k {
case "enabled":
if b, ok := v.(bool); ok {
r.Enabled = b
}
case "metadata":
if m, ok := v.(map[string]any); ok {
r.Metadata = m
}
case "config":
if m, ok := v.(map[string]any); ok {
r.Config = m
}
case "matcher":
if s, ok := v.(string); ok {
r.Matcher = s
}
case "if_expr":
if s, ok := v.(string); ok {
r.IfExpr = s
}
case "priority":
if n, ok := v.(int); ok {
r.Priority = n
}
case "timeout_ms":
if n, ok := v.(int); ok {
r.TimeoutMS = n
}
}
}
r.Version++
return nil
}
func (f *fakeHookStore) Delete(_ context.Context, id uuid.UUID) error {
delete(f.rows, id)
return nil
}
func (f *fakeHookStore) ResolveForEvent(context.Context, hooks.Event) ([]hooks.HookConfig, error) {
return nil, nil
}
func (f *fakeHookStore) WriteExecution(context.Context, hooks.HookExecution) error { return nil }
func (f *fakeHookStore) SetHookAgents(context.Context, uuid.UUID, []uuid.UUID) error { return nil }
func (f *fakeHookStore) GetHookAgents(context.Context, uuid.UUID) ([]uuid.UUID, error) {
return nil, nil
}
type errStr string
func (e errStr) Error() string { return string(e) }
const (
errDuplicate errStr = "fake: duplicate key"
errNotFound errStr = "fake: not found"
)
// withTestRegistry installs a one-spec registry for the test and restores the
// prior state on cleanup. Serial access only — tests must not run in parallel.
func withTestRegistry(t *testing.T, s Spec, src []byte) {
t.Helper()
regMu.Lock()
prevSpecs := specs
prevIdx := eventIDSpec
prevSrc := sourceCache
specs = []Spec{s}
eventIDSpec = map[uuid.UUID]*Spec{}
for _, ev := range s.Events {
eventIDSpec[BuiltinEventID(s.ID, ev)] = &specs[0]
}
sourceCache = map[string][]byte{s.SourceFile: src}
regMu.Unlock()
t.Cleanup(func() {
regMu.Lock()
specs = prevSpecs
eventIDSpec = prevIdx
sourceCache = prevSrc
regMu.Unlock()
})
}
func fixtureSpec(version int) Spec {
return Spec{
ID: "test-redactor",
Version: version,
Events: []string{"user_prompt_submit", "pre_tool_use"},
Scope: "global",
TimeoutMS: 500,
OnTimeout: "allow",
Priority: 900,
MutableFields: []string{"rawInput"},
SourceFile: "test-redactor.js",
Description: "test",
}
}
// TestSeed_InsertsOneRowPerEvent verifies seed creates N rows for a spec with
// N events using stable UUIDv5s — this is the H9 idempotency check.
func TestSeed_InsertsOneRowPerEvent(t *testing.T) {
spec := fixtureSpec(1)
withTestRegistry(t, spec, []byte("// v1"))
fs := newFakeHookStore()
if err := Seed(context.Background(), fs, config.HooksConfig{}); err != nil {
t.Fatalf("Seed: %v", err)
}
if got := len(fs.rows); got != len(spec.Events) {
t.Fatalf("rows=%d, want %d", got, len(spec.Events))
}
for _, ev := range spec.Events {
id := BuiltinEventID(spec.ID, ev)
row, ok := fs.rows[id]
if !ok {
t.Fatalf("missing row for event %q", ev)
}
if row.Source != hooks.SourceBuiltin {
t.Errorf("source=%q, want builtin", row.Source)
}
if !row.Enabled {
t.Error("row disabled; expected enabled=true on fresh seed")
}
}
}
// TestSeed_Idempotent validates H9 — re-running seed against the same registry
// creates zero additional rows. A regression here would manifest as duplicate
// rows every boot once the unique index is dropped by Phase 03.
func TestSeed_Idempotent(t *testing.T) {
spec := fixtureSpec(1)
withTestRegistry(t, spec, []byte("// v1"))
fs := newFakeHookStore()
for i := range 3 {
if err := Seed(context.Background(), fs, config.HooksConfig{}); err != nil {
t.Fatalf("Seed iter=%d: %v", i, err)
}
}
if got := len(fs.rows); got != len(spec.Events) {
t.Fatalf("after 3 seeds rows=%d, want %d (duplicates!)", got, len(spec.Events))
}
}
// TestSeed_VersionBump updates existing rows when the embed version grows.
// Preserves the user's enabled toggle.
func TestSeed_VersionBump(t *testing.T) {
spec := fixtureSpec(1)
withTestRegistry(t, spec, []byte("// v1"))
fs := newFakeHookStore()
if err := Seed(context.Background(), fs, config.HooksConfig{}); err != nil {
t.Fatal(err)
}
// Simulate user disabling a row.
id := BuiltinEventID(spec.ID, "pre_tool_use")
fs.rows[id].Enabled = false
// Replace registry with v2.
withTestRegistry(t, fixtureSpec(2), []byte("// v2"))
if err := Seed(context.Background(), fs, config.HooksConfig{}); err != nil {
t.Fatal(err)
}
if ver := fs.rows[id].Metadata["version"]; ver != 2 {
t.Errorf("version not bumped: %v", ver)
}
if fs.rows[id].Enabled {
t.Error("seed overwrote user's disabled toggle")
}
}
// TestSeed_DowngradeDetected keeps the DB row intact when the embed version
// is older than the DB version (rollback scenario).
func TestSeed_DowngradeDetected(t *testing.T) {
withTestRegistry(t, fixtureSpec(3), []byte("// v3"))
fs := newFakeHookStore()
if err := Seed(context.Background(), fs, config.HooksConfig{}); err != nil {
t.Fatal(err)
}
id := BuiltinEventID("test-redactor", "user_prompt_submit")
gotV := fs.rows[id].Metadata["version"]
// Now simulate rollback to v2 embed.
withTestRegistry(t, fixtureSpec(2), []byte("// v2"))
if err := Seed(context.Background(), fs, config.HooksConfig{}); err != nil {
t.Fatal(err)
}
if gotV2 := fs.rows[id].Metadata["version"]; gotV2 != gotV {
t.Errorf("downgrade mutated row: %v → %v", gotV, gotV2)
}
}
// TestSeed_DefaultDisabledOnFreshInsert verifies a spec carrying
// default_disabled=true creates rows with enabled=false on first insert.
func TestSeed_DefaultDisabledOnFreshInsert(t *testing.T) {
spec := fixtureSpec(1)
spec.DefaultDisabled = true
withTestRegistry(t, spec, []byte("// v1"))
fs := newFakeHookStore()
if err := Seed(context.Background(), fs, config.HooksConfig{}); err != nil {
t.Fatalf("Seed: %v", err)
}
for id, row := range fs.rows {
if row.Enabled {
t.Errorf("row %s enabled despite default_disabled spec", id)
}
}
}
// TestSeed_DefaultDisabledFlipsOnVersionBump ensures the one-shot retroactive
// disable kicks in when a version bump introduces the policy on rows that
// were previously enabled. Subsequent boots (no version bump) leave the
// user's manual re-enable alone.
func TestSeed_DefaultDisabledFlipsOnVersionBump(t *testing.T) {
// Boot 1: spec v1, default_disabled NOT set → row created enabled.
withTestRegistry(t, fixtureSpec(1), []byte("// v1"))
fs := newFakeHookStore()
if err := Seed(context.Background(), fs, config.HooksConfig{}); err != nil {
t.Fatal(err)
}
for _, row := range fs.rows {
if !row.Enabled {
t.Fatal("baseline: row should be enabled before policy change")
}
}
// Boot 2: spec v2 with default_disabled=true → flips existing enabled rows.
v2 := fixtureSpec(2)
v2.DefaultDisabled = true
withTestRegistry(t, v2, []byte("// v2"))
if err := Seed(context.Background(), fs, config.HooksConfig{}); err != nil {
t.Fatal(err)
}
for id, row := range fs.rows {
if row.Enabled {
t.Errorf("row %s still enabled after policy version bump", id)
}
}
// User re-enables one row through the UI.
for id := range fs.rows {
fs.rows[id].Enabled = true
break
}
// Boot 3: same spec v2 (no version bump) → user's choice is preserved.
if err := Seed(context.Background(), fs, config.HooksConfig{}); err != nil {
t.Fatal(err)
}
enabledAfter := 0
for _, row := range fs.rows {
if row.Enabled {
enabledAfter++
}
}
if enabledAfter != 1 {
t.Errorf("user re-enable lost: enabled=%d, want 1", enabledAfter)
}
}
// TestSeed_BuiltinDisableForcesOff applies the operator escape-hatch list.
func TestSeed_BuiltinDisableForcesOff(t *testing.T) {
withTestRegistry(t, fixtureSpec(1), []byte("// v1"))
fs := newFakeHookStore()
cfg := config.HooksConfig{BuiltinDisable: []string{"test-redactor"}}
if err := Seed(context.Background(), fs, cfg); err != nil {
t.Fatal(err)
}
for id, row := range fs.rows {
if row.Enabled {
t.Errorf("row %s enabled despite builtin_disable", id)
}
}
}