Files
kandev/apps/backend/internal/automation/export_ac22_test.go
T
2026-08-24 11:09:03 +00:00

87 lines
4.0 KiB
Go

package automation
import (
"reflect"
"testing"
)
// fieldDisposition records what AC-22 calls a field's "disposition": either exported
// under a named YAML key, or excluded for a stated reason. See
// docs/specs/office/requirements/automations-yaml-export.md § Round-trip completeness, the field
// disposition table under "Data model".
//
// This is the anti-`reports_to` guard: Office's config export declared and emitted an
// agent field that was silently dropped on import because no test asserted every field
// had a disposition. A field added to Automation or AutomationTrigger without an entry
// here fails TestAC22_* immediately, forcing an explicit export/exclude decision.
type fieldDisposition struct {
exported bool
yamlKey string // required when exported; documents the emitted key/path
}
var automationFieldDispositions = map[string]fieldDisposition{
"Name": {exported: true, yamlKey: "name"},
"Description": {exported: true, yamlKey: "description"},
"Prompt": {exported: true, yamlKey: "prompt"},
"TaskTitleTemplate": {exported: true, yamlKey: "task_title_template"},
"Enabled": {exported: true, yamlKey: "enabled"},
"MaxConcurrentRuns": {exported: true, yamlKey: "max_concurrent_runs"},
"Triggers": {exported: true, yamlKey: "triggers"},
"WorkflowID": {exported: true, yamlKey: "workflow.name"},
"WorkflowStepID": {exported: true, yamlKey: "workflow.step"},
"AgentProfileID": {exported: true, yamlKey: "agent_profile"},
"ExecutorProfileID": {exported: true, yamlKey: "executor_profile"},
"RepositoryIDs": {exported: true, yamlKey: "repositories"},
"WebhookSecret": {exported: false}, // secret
"ID": {exported: false}, // instance identity
"WorkspaceID": {exported: false}, // instance identity
"LastTriggeredAt": {exported: false}, // runtime state / fire anchor
"CreatedAt": {exported: false}, // runtime state / fire anchor
"UpdatedAt": {exported: false}, // runtime state
"LegacyBoardCard": {exported: false}, // derived from withdrawn execution_mode column
}
var automationTriggerFieldDispositions = map[string]fieldDisposition{
"Type": {exported: true, yamlKey: "type"},
"Enabled": {exported: true, yamlKey: "enabled"},
"Config": {exported: true, yamlKey: "config"}, // decoded form
"ConfigJSON": {exported: false}, // raw storage of Config; same value
"ID": {exported: false}, // instance identity
"AutomationID": {exported: false}, // instance identity
"LastEvaluatedAt": {exported: false}, // runtime state / fire anchor
"CreatedAt": {exported: false}, // runtime state / fire anchor
"UpdatedAt": {exported: false}, // runtime state
}
func TestAC22_AutomationFieldDisposition(t *testing.T) {
assertFieldDispositionsCoverStruct(t, reflect.TypeOf(Automation{}), automationFieldDispositions)
}
func TestAC22_AutomationTriggerFieldDisposition(t *testing.T) {
assertFieldDispositionsCoverStruct(t, reflect.TypeOf(AutomationTrigger{}), automationTriggerFieldDispositions)
}
// assertFieldDispositionsCoverStruct fails when a struct field has no recorded
// disposition (drift: a new field shipped without an export decision) or when a
// recorded disposition names a field that no longer exists (drift: a stale entry
// survived a rename/removal).
func assertFieldDispositionsCoverStruct(t *testing.T, typ reflect.Type, dispositions map[string]fieldDisposition) {
t.Helper()
seen := make(map[string]bool, typ.NumField())
for i := 0; i < typ.NumField(); i++ {
name := typ.Field(i).Name
if _, ok := dispositions[name]; !ok {
t.Errorf("field %s.%s has no recorded AC-22 disposition; add it to the export field-disposition table and either export it under a yamlKey or exclude it with a reason", typ.Name(), name)
continue
}
seen[name] = true
}
for name := range dispositions {
if !seen[name] {
t.Errorf("recorded AC-22 disposition for %s.%s does not match any struct field; remove the stale entry", typ.Name(), name)
}
}
}