Ports the GraphMCP-Example substrate into lore-engine-poc: - 8 Go workers under workers/ (discord-connector, discord-filter, lore-watcher, ingestion-worker, entity-extractor, lore-extractor, encounter-processor, mcp-server), each with Dockerfile + go.mod - 3 Go unit-test files (encounter-processor, ingestion-worker, lore-extractor) — other 5 workers rely on integration tests via the live stack - plugins/nsc.py: thin httpx proxy from gateway to lore-mcp-server:9000, exposes all 11 inherited GraphMCP tools (input schemas verbatim from mcp-server/main.go) - docker-compose.yml: adds lore-redis + lore-mcp-server + the 7 worker services (lore- prefix to avoid clash with other GraphMCP stacks) - verify-merge.sh (171 LOC, 7 pass conditions) + docs/VERIFICATION.md - tests/contract/test_graphmcp_tool_contracts.py (15 tests; skipped when stack is down — TDD pattern, becomes active once docker compose up brings the stack) - README.md + test.sh updated for the merged service inventory Leader notes (2026-06-27 03:50): - Worker self-blocked review-required after 2 runs (run #7 hit 120/120 iteration budget; run #8 staged 40 files and reported shippable). - Tests are SKIPPED until docker compose up — worker chose that pattern over mocking (consistent with the lore-engine-poc project convention). To activate, run `docker compose up -d --build && pytest tests/contract/`. - File Scope reconciliation: story said gateway/plugins/nsc/__init__.py; worker shipped plugins/nsc.py (flat file). Justified by the existing plugins/ convention in lore-engine-poc (server.py glob("*.py")). A future PR could split nsc into a package once server.py learns __init__.py discovery. - nsc plugin exposes 11 tools (not 8) — the AC said "8" but the worker enumerated all 11 tools present in mcp-server/main.go. The encounter-specific 3 tools (list_encounters, search_encounters, get_encounter) were included for consistency. Story AC #2 reads "≥ 8 GraphMCP tools" so this exceeds AC. Refs: S2-phase-1-substrate-merge, milestone #64 P1 — Substrate merge
140 lines
4.6 KiB
Go
140 lines
4.6 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ── repairJSON ────────────────────────────────────────────────────────────────
|
|
|
|
func TestRepairJSON(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
want string
|
|
}{
|
|
{`{"a": 1,}`, `{"a": 1}`},
|
|
{`[1, 2,]`, `[1, 2]`},
|
|
{`{"a": 1, "b": 2}`, `{"a": 1, "b": 2}`}, // already valid, unchanged
|
|
{`{"entities": [],}`, `{"entities": []}`},
|
|
}
|
|
for _, tt := range tests {
|
|
got := repairJSON(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("repairJSON(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── stripFences ───────────────────────────────────────────────────────────────
|
|
|
|
func TestStripFences(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{"json fence", "```json\n{}\n```", "{}"},
|
|
{"plain fence", "```\n{}\n```", "{}"},
|
|
{"no fence", "{}", "{}"},
|
|
{"leading whitespace with fence", " ```json\n{}\n``` ", "{}"},
|
|
{"fence with content", "```json\n{\"a\":1}\n```", `{"a":1}`},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := stripFences(tt.input)
|
|
if got != tt.want {
|
|
t.Errorf("stripFences(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── fixUnicodeEscapes ─────────────────────────────────────────────────────────
|
|
|
|
func TestFixUnicodeEscapes(t *testing.T) {
|
|
t.Run("valid escape is preserved", func(t *testing.T) {
|
|
input := `{"name": "Théron"}`
|
|
got := fixUnicodeEscapes(input)
|
|
if got != input {
|
|
t.Errorf("fixUnicodeEscapes modified valid escape: got %q", got)
|
|
}
|
|
// Must still be valid JSON
|
|
if err := json.Unmarshal([]byte(got), &map[string]any{}); err != nil {
|
|
t.Errorf("result not valid JSON: %v", err)
|
|
}
|
|
})
|
|
|
|
t.Run("invalid hex char is stripped", func(t *testing.T) {
|
|
// \u201g — 'g' is not a hex digit
|
|
input := `{"name": "test\u201gname"}`
|
|
got := fixUnicodeEscapes(input)
|
|
if strings.Contains(got, `\u`) {
|
|
t.Errorf("fixUnicodeEscapes left invalid escape in: %q", got)
|
|
}
|
|
// Result must be parseable JSON
|
|
if err := json.Unmarshal([]byte(got), &map[string]any{}); err != nil {
|
|
t.Errorf("result not valid JSON after fix: %v — got: %q", err, got)
|
|
}
|
|
})
|
|
|
|
t.Run("truncated escape at end is dropped", func(t *testing.T) {
|
|
input := `{"name": "ab\u00"}`
|
|
got := fixUnicodeEscapes(input)
|
|
if strings.Contains(got, `\u`) {
|
|
t.Errorf("incomplete escape still present: %q", got)
|
|
}
|
|
})
|
|
}
|
|
|
|
// ── coerceString ─────────────────────────────────────────────────────────────
|
|
|
|
func TestCoerceString(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want string
|
|
}{
|
|
{"plain string", `"Theron Ashveil"`, "Theron Ashveil"},
|
|
{"name object", `{"name": "The Iron Council"}`, "The Iron Council"},
|
|
{"single-element array", `["Thornwall Keep"]`, "Thornwall Keep"},
|
|
{"empty string", `""`, ""},
|
|
{"nested array with object", `[{"name": "Deep Keep"}]`, "Deep Keep"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := coerceString(json.RawMessage(tt.input))
|
|
if got != tt.want {
|
|
t.Errorf("coerceString(%q) = %q, want %q", tt.input, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── salvageEntities ───────────────────────────────────────────────────────────
|
|
|
|
func TestSalvageEntities(t *testing.T) {
|
|
t.Run("recovers entities when relations are malformed", func(t *testing.T) {
|
|
// Simulate a response where relations block is broken
|
|
raw := `{"entities": [{"name": "Gromm", "type": "Person"}], "relations": [BROKEN`
|
|
result := salvageEntities(raw)
|
|
if result == nil {
|
|
t.Fatal("expected salvaged result, got nil")
|
|
}
|
|
if len(result.Entities) != 1 || result.Entities[0].Name != "Gromm" {
|
|
t.Errorf("unexpected entities: %+v", result.Entities)
|
|
}
|
|
if len(result.Relations) != 0 {
|
|
t.Errorf("expected empty relations, got %d", len(result.Relations))
|
|
}
|
|
})
|
|
|
|
t.Run("returns nil when entities are also malformed", func(t *testing.T) {
|
|
raw := `{"entities": [BROKEN], "relations": []}`
|
|
result := salvageEntities(raw)
|
|
if result != nil {
|
|
t.Errorf("expected nil, got %+v", result)
|
|
}
|
|
})
|
|
}
|