mirror of
https://github.com/tiennm99/goclaw.git
synced 2026-10-13 02:14:39 +00:00
isAllCapsPlaceholder() classified ANY string of >=3 chars made only of A-Z and
underscore as an LLM placeholder, and stripEmptyOptionalArgs() then removed it
from the outbound CallTool arguments. Real business identifiers match that shape:
bank codes BKASH, NAGAD, ROCKET, VCB
currency codes BDT, VND, USD, PKR, MMK
enum values SUCCESS, PENDING, ALL
Impact is silent and severe: dropping an optional filter turns a filtered query
into an unfiltered one. The MCP server still answers 200 with the full result set,
so nothing errors — the model reports totals for the entire dataset as if they were
the filtered result, with full confidence.
Observed in production: asking "how many BKASH proxies are active" returned 6,610
(all banks) instead of 1,263. Three different bank codes returned byte-identical
results, which also drove the model into a retry loop until the loop guard fired.
Diagnosis was slow because the removal is invisible: the outbound log records
args_len BEFORE stripping, and stripEmptyOptionalArgs logs nothing at all — while
normalizeArgsForSchema right below it does emit mcp.tool.args.coerced. Confirmed
the argument never reaches the server by echoing the raw JSON-RPC arguments from
RequestContext[CallToolRequestParams] on the server side.
Changes:
- isAllCapsPlaceholder: require multi-word (contains "_") or membership in a known
single-word placeholder list. Keeps the original intent — the doc comment's own
examples (SHOULD_NOT_BE_HERE, DO_NOT_SEND, NOT_APPLICABLE) are all multi-word —
while letting business codes through.
- stripEmptyOptionalArgs: emit slog.Warn("mcp.tool.args.stripped") whenever an
argument is dropped, so this class of bug is one grep away instead of a
multi-hour investigation.
- Tests: guard both layers against regression.
Verified: go build ./... clean; go test ./internal/mcp/ passes.
549 lines
16 KiB
Go
549 lines
16 KiB
Go
package mcp
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/google/uuid"
|
|
mcpgo "github.com/mark3labs/mcp-go/mcp"
|
|
)
|
|
|
|
func TestInputSchemaToMap(t *testing.T) {
|
|
schema := mcpgo.ToolInputSchema{
|
|
Type: "object",
|
|
Properties: map[string]any{
|
|
"query": map[string]any{
|
|
"type": "string",
|
|
"description": "Search query",
|
|
},
|
|
},
|
|
Required: []string{"query"},
|
|
}
|
|
|
|
m := inputSchemaToMap(schema)
|
|
|
|
if m["type"] != "object" {
|
|
t.Errorf("expected type=object, got %v", m["type"])
|
|
}
|
|
|
|
props, ok := m["properties"].(map[string]any)
|
|
if !ok || props == nil {
|
|
t.Fatal("expected properties map")
|
|
}
|
|
if _, ok := props["query"]; !ok {
|
|
t.Error("expected 'query' in properties")
|
|
}
|
|
|
|
req, ok := m["required"].([]string)
|
|
if !ok || len(req) != 1 || req[0] != "query" {
|
|
t.Errorf("expected required=[query], got %v", m["required"])
|
|
}
|
|
}
|
|
|
|
func TestInputSchemaToMap_EmptyType(t *testing.T) {
|
|
schema := mcpgo.ToolInputSchema{}
|
|
m := inputSchemaToMap(schema)
|
|
|
|
if m["type"] != "object" {
|
|
t.Errorf("expected default type=object, got %v", m["type"])
|
|
}
|
|
}
|
|
|
|
func TestInputSchemaToMap_ObjectNoProperties(t *testing.T) {
|
|
schema := mcpgo.ToolInputSchema{Type: "object"}
|
|
m := inputSchemaToMap(schema)
|
|
|
|
props, ok := m["properties"].(map[string]any)
|
|
if !ok || props == nil {
|
|
t.Fatal("expected empty properties map for object schema, got nil — OpenAI rejects object schemas without properties")
|
|
}
|
|
if len(props) != 0 {
|
|
t.Errorf("expected empty properties, got %v", props)
|
|
}
|
|
}
|
|
|
|
func TestExtractTextContent(t *testing.T) {
|
|
result := &mcpgo.CallToolResult{
|
|
Content: []mcpgo.Content{
|
|
mcpgo.TextContent{Type: "text", Text: "hello"},
|
|
mcpgo.TextContent{Type: "text", Text: "world"},
|
|
},
|
|
}
|
|
|
|
got := extractTextContent(result)
|
|
if got != "hello\nworld" {
|
|
t.Errorf("expected 'hello\\nworld', got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestExtractTextContent_Nil(t *testing.T) {
|
|
if got := extractTextContent(nil); got != "" {
|
|
t.Errorf("expected empty for nil, got %q", got)
|
|
}
|
|
|
|
result := &mcpgo.CallToolResult{}
|
|
if got := extractTextContent(result); got != "" {
|
|
t.Errorf("expected empty for no content, got %q", got)
|
|
}
|
|
}
|
|
|
|
func TestBridgeToolNaming(t *testing.T) {
|
|
mcpTool := mcpgo.Tool{
|
|
Name: "query",
|
|
Description: "Run a query",
|
|
InputSchema: mcpgo.ToolInputSchema{Type: "object"},
|
|
}
|
|
|
|
// Without prefix → auto-derived from server name
|
|
bt := NewBridgeTool("myserver", mcpTool, nil, "", 30, nil, uuid.Nil, nil)
|
|
if bt.Name() != "mcp_myserver__query" {
|
|
t.Errorf("expected name=mcp_myserver__query, got %s", bt.Name())
|
|
}
|
|
if bt.ServerName() != "myserver" {
|
|
t.Errorf("expected serverName=myserver, got %s", bt.ServerName())
|
|
}
|
|
if bt.OriginalName() != "query" {
|
|
t.Errorf("expected originalName=query, got %s", bt.OriginalName())
|
|
}
|
|
|
|
// With non-mcp_ prefix → gets mcp_ prepended
|
|
bt2 := NewBridgeTool("myserver", mcpTool, nil, "pg", 0, nil, uuid.Nil, nil)
|
|
if bt2.Name() != "mcp_pg__query" {
|
|
t.Errorf("expected name=mcp_pg__query, got %s", bt2.Name())
|
|
}
|
|
if bt2.OriginalName() != "query" {
|
|
t.Errorf("expected originalName=query, got %s", bt2.OriginalName())
|
|
}
|
|
|
|
// With mcp_ prefix → unchanged
|
|
bt3 := NewBridgeTool("myserver", mcpTool, nil, "mcp_pg", 0, nil, uuid.Nil, nil)
|
|
if bt3.Name() != "mcp_pg__query" {
|
|
t.Errorf("expected name=mcp_pg__query, got %s", bt3.Name())
|
|
}
|
|
|
|
// Server name with hyphens → sanitized to underscores
|
|
bt4 := NewBridgeTool("my-server", mcpTool, nil, "", 0, nil, uuid.Nil, nil)
|
|
if bt4.Name() != "mcp_my_server__query" {
|
|
t.Errorf("expected name=mcp_my_server__query, got %s", bt4.Name())
|
|
}
|
|
|
|
// Default timeout
|
|
if bt2.timeoutSec != 60 {
|
|
t.Errorf("expected default timeout=60, got %d", bt2.timeoutSec)
|
|
}
|
|
}
|
|
|
|
func TestBridgeToolWithHints(t *testing.T) {
|
|
mcpTool := mcpgo.Tool{
|
|
Name: "search",
|
|
Description: "Run a search",
|
|
InputSchema: mcpgo.ToolInputSchema{Type: "object"},
|
|
}
|
|
|
|
// No hints → original description unchanged
|
|
bt := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil)
|
|
if bt.Description() != "Run a search" {
|
|
t.Errorf("expected unchanged description, got %q", bt.Description())
|
|
}
|
|
|
|
// Global hint only
|
|
bt2 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil).
|
|
WithHints("No trailing semicolons.", "")
|
|
got := bt2.Description()
|
|
if got != "Run a search\n\n[Server hint] No trailing semicolons." {
|
|
t.Errorf("global-only mismatch:\n%q", got)
|
|
}
|
|
|
|
// Per-tool hint only
|
|
bt3 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil).
|
|
WithHints("", "Use arrow func.")
|
|
if bt3.Description() != "Run a search\n\n[Tool hint] Use arrow func." {
|
|
t.Errorf("tool-only mismatch: %q", bt3.Description())
|
|
}
|
|
|
|
// Both hints — order: global then tool
|
|
bt4 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil).
|
|
WithHints("G.", "T.")
|
|
if bt4.Description() != "Run a search\n\n[Server hint] G.\n\n[Tool hint] T." {
|
|
t.Errorf("combined mismatch: %q", bt4.Description())
|
|
}
|
|
|
|
// Whitespace-only hints → treated as empty (no suffix)
|
|
bt5 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil).
|
|
WithHints(" \n ", "\t")
|
|
if bt5.Description() != "Run a search" {
|
|
t.Errorf("whitespace-only hints should render no suffix, got %q", bt5.Description())
|
|
}
|
|
|
|
// WithHints can be chained and reset by re-calling
|
|
bt6 := NewBridgeTool("srv", mcpTool, nil, "", 30, nil, uuid.Nil, nil).
|
|
WithHints("first", "hint")
|
|
bt6.WithHints("", "")
|
|
if bt6.Description() != "Run a search" {
|
|
t.Errorf("calling WithHints with empty should clear suffix, got %q", bt6.Description())
|
|
}
|
|
}
|
|
|
|
func TestIsPlaceholderValue(t *testing.T) {
|
|
// Should be detected as placeholder.
|
|
placeholders := []string{
|
|
"null", "None", "nil", "UNDEFINED", "n/a",
|
|
"optional", "Optional", "OPTIONAL",
|
|
"skip", "Skip",
|
|
"__OMIT__", "__skip__", "__EMPTY__",
|
|
"http://example.com", "https://example.com",
|
|
"http://localhost", "https://localhost",
|
|
"PLACEHOLDER", "NOT_SET", "DO_NOT_SEND",
|
|
}
|
|
for _, s := range placeholders {
|
|
if !isPlaceholderValue(s) {
|
|
t.Errorf("expected isPlaceholderValue(%q) = true", s)
|
|
}
|
|
}
|
|
|
|
// Should NOT be detected as placeholder (real values).
|
|
realValues := []string{
|
|
"", // empty string handled separately by type-aware check
|
|
"sk-abc123",
|
|
"my-proxy.example.com",
|
|
"https://api.reviewweb.site/v1",
|
|
"gpt-4o-mini",
|
|
"bullet",
|
|
"hello world",
|
|
"ab", // too short for all-caps check
|
|
}
|
|
for _, s := range realValues {
|
|
if isPlaceholderValue(s) {
|
|
t.Errorf("expected isPlaceholderValue(%q) = false", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestStripEmptyOptionalArgs(t *testing.T) {
|
|
bt := &BridgeTool{
|
|
requiredSet: map[string]bool{"url": true},
|
|
inputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"url": map[string]any{"type": "string"},
|
|
"api_key": map[string]any{"type": "string"},
|
|
"timeout": map[string]any{"type": "number"},
|
|
"debug": map[string]any{"type": "boolean"},
|
|
"keywords": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
}
|
|
|
|
args := map[string]any{
|
|
"url": "https://example.com",
|
|
"api_key": "optional", // placeholder → strip
|
|
"timeout": nil, // nil → strip
|
|
"debug": true, // real boolean → keep
|
|
"keywords": "", // empty string for string-typed → keep
|
|
}
|
|
|
|
cleaned := bt.stripEmptyOptionalArgs(args)
|
|
|
|
if cleaned["url"] != "https://example.com" {
|
|
t.Error("required param 'url' should be preserved")
|
|
}
|
|
if _, ok := cleaned["api_key"]; ok {
|
|
t.Error("placeholder 'optional' should be stripped for api_key")
|
|
}
|
|
if _, ok := cleaned["timeout"]; ok {
|
|
t.Error("nil should be stripped for timeout")
|
|
}
|
|
if cleaned["debug"] != true {
|
|
t.Error("real boolean value should be preserved")
|
|
}
|
|
if v, ok := cleaned["keywords"]; !ok || v != "" {
|
|
t.Error("empty string should be kept for string-typed optional param 'keywords'")
|
|
}
|
|
}
|
|
|
|
func TestStripEmptyOptionalArgs_EmptyStringNonString(t *testing.T) {
|
|
bt := &BridgeTool{
|
|
requiredSet: map[string]bool{},
|
|
inputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"timeout": map[string]any{"type": "number"},
|
|
"count": map[string]any{"type": "integer"},
|
|
},
|
|
},
|
|
}
|
|
|
|
args := map[string]any{
|
|
"timeout": "",
|
|
"count": "",
|
|
}
|
|
|
|
cleaned := bt.stripEmptyOptionalArgs(args)
|
|
|
|
if _, ok := cleaned["timeout"]; ok {
|
|
t.Error("empty string should be stripped for number-typed param")
|
|
}
|
|
if _, ok := cleaned["count"]; ok {
|
|
t.Error("empty string should be stripped for integer-typed param")
|
|
}
|
|
}
|
|
|
|
func TestNormalizeArgsForSchema_CoercesStringifiedLarkTaskPatchArgs(t *testing.T) {
|
|
bt := &BridgeTool{
|
|
inputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"path": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"task_guid": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
"params": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"user_id_type": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
"data": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"task": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"completed_at": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
"update_fields": map[string]any{
|
|
"type": "array",
|
|
"items": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
args := map[string]any{
|
|
"path": `{"task_guid":"2d446187-084d-4d45-883b-5d238242b9fc"}`,
|
|
"params": `{"user_id_type":"open_id"}`,
|
|
"data": `{"task":{"completed_at":"1782906226000"},"update_fields":["completed_at"]}`,
|
|
}
|
|
|
|
normalized, coercedPaths, err := bt.normalizeArgsForSchema(args)
|
|
if err != nil {
|
|
t.Fatalf("normalizeArgsForSchema returned error: %v", err)
|
|
}
|
|
|
|
pathArg, ok := normalized["path"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("path should be object after normalization, got %T", normalized["path"])
|
|
}
|
|
if pathArg["task_guid"] != "2d446187-084d-4d45-883b-5d238242b9fc" {
|
|
t.Errorf("unexpected task_guid: %v", pathArg["task_guid"])
|
|
}
|
|
|
|
dataArg, ok := normalized["data"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("data should be object after normalization, got %T", normalized["data"])
|
|
}
|
|
taskArg, ok := dataArg["task"].(map[string]any)
|
|
if !ok {
|
|
t.Fatalf("data.task should be object after normalization, got %T", dataArg["task"])
|
|
}
|
|
if taskArg["completed_at"] != "1782906226000" {
|
|
t.Errorf("unexpected completed_at: %v", taskArg["completed_at"])
|
|
}
|
|
|
|
wantPaths := map[string]bool{"$.path": true, "$.params": true, "$.data": true}
|
|
for _, path := range coercedPaths {
|
|
delete(wantPaths, path)
|
|
}
|
|
if len(wantPaths) != 0 {
|
|
t.Errorf("missing coerced paths: %v; got %v", wantPaths, coercedPaths)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeArgsForSchema_CoercesNestedStringifiedContainers(t *testing.T) {
|
|
bt := &BridgeTool{
|
|
inputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"data": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"task": map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"completed_at": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
"update_fields": map[string]any{
|
|
"type": "array",
|
|
"items": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
args := map[string]any{
|
|
"data": map[string]any{
|
|
"task": `{"completed_at":"1782906226000"}`,
|
|
"update_fields": `["completed_at"]`,
|
|
},
|
|
}
|
|
|
|
normalized, coercedPaths, err := bt.normalizeArgsForSchema(args)
|
|
if err != nil {
|
|
t.Fatalf("normalizeArgsForSchema returned error: %v", err)
|
|
}
|
|
|
|
dataArg := normalized["data"].(map[string]any)
|
|
if _, ok := dataArg["task"].(map[string]any); !ok {
|
|
t.Fatalf("data.task should be object after normalization, got %T", dataArg["task"])
|
|
}
|
|
if fields, ok := dataArg["update_fields"].([]any); !ok || len(fields) != 1 || fields[0] != "completed_at" {
|
|
t.Fatalf("update_fields should be array after normalization, got %#v", dataArg["update_fields"])
|
|
}
|
|
|
|
wantPaths := map[string]bool{"$.data.task": true, "$.data.update_fields": true}
|
|
for _, path := range coercedPaths {
|
|
delete(wantPaths, path)
|
|
}
|
|
if len(wantPaths) != 0 {
|
|
t.Errorf("missing coerced paths: %v; got %v", wantPaths, coercedPaths)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeArgsForSchema_RejectsStringifiedContainerWithWrongShape(t *testing.T) {
|
|
bt := &BridgeTool{
|
|
inputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"path": map[string]any{"type": "object"},
|
|
},
|
|
},
|
|
}
|
|
|
|
_, _, err := bt.normalizeArgsForSchema(map[string]any{"path": `["not","object"]`})
|
|
if err == nil {
|
|
t.Fatal("expected error for array string when schema requires object")
|
|
}
|
|
if !strings.Contains(err.Error(), "$.path must be object") {
|
|
t.Fatalf("expected actionable path/type error, got: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestNormalizeArgsForSchema_DoesNotCoerceStringTypedProperty(t *testing.T) {
|
|
bt := &BridgeTool{
|
|
inputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"content": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
}
|
|
|
|
args := map[string]any{"content": `{"literal":"json text"}`}
|
|
normalized, coercedPaths, err := bt.normalizeArgsForSchema(args)
|
|
if err != nil {
|
|
t.Fatalf("normalizeArgsForSchema returned error: %v", err)
|
|
}
|
|
if normalized["content"] != `{"literal":"json text"}` {
|
|
t.Errorf("string-typed property should remain unchanged, got %#v", normalized["content"])
|
|
}
|
|
if len(coercedPaths) != 0 {
|
|
t.Errorf("string-typed property should not be coerced, got paths %v", coercedPaths)
|
|
}
|
|
}
|
|
|
|
func TestEnsureMCPPrefix(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
prefix string
|
|
serverName string
|
|
want string
|
|
}{
|
|
{"empty prefix", "", "vnstock", "mcp_vnstock"},
|
|
{"empty prefix hyphenated server", "", "my-server", "mcp_my_server"},
|
|
{"non-mcp prefix", "pg", "postgres", "mcp_pg"},
|
|
{"already mcp_ prefix", "mcp_pg", "postgres", "mcp_pg"},
|
|
{"mcp prefix without underscore", "mcp", "x", "mcp_mcp"},
|
|
{"custom prefix with underscores", "vnstock", "vnstock", "mcp_vnstock"},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := ensureMCPPrefix(tt.prefix, tt.serverName)
|
|
if got != tt.want {
|
|
t.Errorf("ensureMCPPrefix(%q, %q) = %q, want %q", tt.prefix, tt.serverName, got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestIsAllCapsPlaceholder_KeepsBusinessCodes guards a production incident:
|
|
// all-caps business identifiers (bank codes, currency codes, enum values) were
|
|
// classified as LLM placeholders and silently dropped from tool arguments, so a
|
|
// filtered query silently became an unfiltered one and the model reported totals
|
|
// for the whole dataset as if they were the filtered result.
|
|
func TestIsAllCapsPlaceholder_KeepsBusinessCodes(t *testing.T) {
|
|
keep := []string{
|
|
"BKASH", "NAGAD", "ROCKET", "ROCKETC", // bank codes
|
|
"BDT", "VND", "USD", "PKR", "MMK", // currency codes
|
|
"SUCCESS", "PENDING", "ALL", // enum values
|
|
"VCB", "SPACEPROXY",
|
|
}
|
|
for _, s := range keep {
|
|
if isAllCapsPlaceholder(s) {
|
|
t.Errorf("business identifier %q must NOT be treated as a placeholder", s)
|
|
}
|
|
}
|
|
|
|
drop := []string{
|
|
"SHOULD_NOT_BE_HERE", "DO_NOT_SEND", "NOT_APPLICABLE", // multi-word
|
|
"PLACEHOLDER", "TODO", "TBD", "XXX", "CHANGEME", // known single-word
|
|
}
|
|
for _, s := range drop {
|
|
if !isAllCapsPlaceholder(s) {
|
|
t.Errorf("placeholder %q must be detected", s)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestStripEmptyOptionalArgs_KeepsAllCapsBusinessCode is the end-to-end guard for
|
|
// the same incident, at the layer that actually mutates the outbound arguments.
|
|
func TestStripEmptyOptionalArgs_KeepsAllCapsBusinessCode(t *testing.T) {
|
|
bt := &BridgeTool{
|
|
requiredSet: map[string]bool{"merchant_id": true},
|
|
inputSchema: map[string]any{
|
|
"type": "object",
|
|
"properties": map[string]any{
|
|
"merchant_id": map[string]any{"type": "string"},
|
|
"bank_code": map[string]any{"type": "string"},
|
|
"currency": map[string]any{"type": "string"},
|
|
"note": map[string]any{"type": "string"},
|
|
},
|
|
},
|
|
}
|
|
|
|
cleaned := bt.stripEmptyOptionalArgs(map[string]any{
|
|
"merchant_id": "CPS_C1",
|
|
"bank_code": "BKASH", // optional all-caps business code → keep
|
|
"currency": "BDT", // optional all-caps business code → keep
|
|
"note": "SHOULD_NOT_BE_HERE", // real placeholder → strip
|
|
})
|
|
|
|
if cleaned["bank_code"] != "BKASH" {
|
|
t.Errorf("bank_code must survive, got %v", cleaned["bank_code"])
|
|
}
|
|
if cleaned["currency"] != "BDT" {
|
|
t.Errorf("currency must survive, got %v", cleaned["currency"])
|
|
}
|
|
if _, ok := cleaned["note"]; ok {
|
|
t.Error("multi-word all-caps placeholder should still be stripped")
|
|
}
|
|
}
|