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
98 lines
2.9 KiB
Go
98 lines
2.9 KiB
Go
package main
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// ── parseParticipants ─────────────────────────────────────────────────────────
|
|
|
|
func TestParseParticipants(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
want []string
|
|
}{
|
|
{
|
|
name: "three clean names",
|
|
input: "Alice, Bob, Charlie",
|
|
want: []string{"Alice", "Bob", "Charlie"},
|
|
},
|
|
{
|
|
name: "extra whitespace is trimmed",
|
|
input: " Alice , Bob ",
|
|
want: []string{"Alice", "Bob"},
|
|
},
|
|
{
|
|
name: "single participant",
|
|
input: "Gromm The Timeless",
|
|
want: []string{"Gromm The Timeless"},
|
|
},
|
|
{
|
|
name: "empty string returns empty slice",
|
|
input: "",
|
|
want: nil,
|
|
},
|
|
{
|
|
name: "empty segments are dropped",
|
|
input: "Alice,,Bob",
|
|
want: []string{"Alice", "Bob"},
|
|
},
|
|
{
|
|
name: "whitespace-only segment is dropped",
|
|
input: "Alice, ,Bob",
|
|
want: []string{"Alice", "Bob"},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := parseParticipants(tt.input)
|
|
if len(got) != len(tt.want) {
|
|
t.Fatalf("parseParticipants(%q) = %v, want %v", tt.input, got, tt.want)
|
|
}
|
|
for i, name := range got {
|
|
if name != tt.want[i] {
|
|
t.Errorf("got[%d] = %q, want %q", i, name, tt.want[i])
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// ── Cypher constant integrity ─────────────────────────────────────────────────
|
|
|
|
func TestResolveEntityQueryShape(t *testing.T) {
|
|
if !strings.Contains(resolveEntityQuery, "$name") {
|
|
t.Error("resolveEntityQuery must reference $name parameter")
|
|
}
|
|
if !strings.Contains(resolveEntityQuery, "aliases") {
|
|
t.Error("resolveEntityQuery must check aliases array")
|
|
}
|
|
if !strings.Contains(resolveEntityQuery, "lore_verified") {
|
|
t.Error("resolveEntityQuery must filter on lore_verified")
|
|
}
|
|
if !strings.Contains(resolveEntityQuery, "LIMIT 1") {
|
|
t.Error("resolveEntityQuery must return at most one result")
|
|
}
|
|
}
|
|
|
|
func TestProvisionalCypherSetsFlag(t *testing.T) {
|
|
if !strings.Contains(mergeWitnessedProvisional, "lore_verified") {
|
|
t.Error("mergeWitnessedProvisional must set lore_verified flag")
|
|
}
|
|
if !strings.Contains(mergeLocationProvisional, "lore_verified") {
|
|
t.Error("mergeLocationProvisional must set lore_verified flag")
|
|
}
|
|
}
|
|
|
|
func TestCanonicalCypherUsesMatchNotMerge(t *testing.T) {
|
|
// Canonical writes must MATCH (not MERGE) so they fail loudly if the
|
|
// canonical node was somehow deleted, rather than silently creating a dup.
|
|
if !strings.HasPrefix(strings.TrimSpace(mergeWitnessedCanonical), "MATCH") {
|
|
t.Error("mergeWitnessedCanonical should start with MATCH to avoid silent creation")
|
|
}
|
|
if !strings.HasPrefix(strings.TrimSpace(mergeLocationCanonical), "MATCH") {
|
|
t.Error("mergeLocationCanonical should start with MATCH to avoid silent creation")
|
|
}
|
|
}
|