From 945f1b95f330bd7d6cea72f2d86f64397ff28445 Mon Sep 17 00:00:00 2001 From: sujan reddy Date: Mon, 31 Aug 2026 16:12:37 -0500 Subject: [PATCH] test(seller-agent): add MCP-level tool tests and cover force_account_status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit reference/seller-agent already had strong backend-method-level tests for most of issue #140's acceptance list (create_media_buy, pending_creatives -> active, cancel/double-cancel, list_creatives filtering, delivery reporting, and the seed_product/seed_pricing_option/force_create_media_buy_arm custom comply_test_controller scenarios), added incrementally after #138 merged. Two real gaps remained: - forceAccountStatus (the force_account_status compliance scenario) had no test at all. - every existing test calls backend methods directly, bypassing the actual adcp.Register/adcp.AddTool/adcp.RegisterTestController wiring main() uses to serve requests — so a regression in tool names or request/response field mapping would only be caught by the npm storyboard runner. Extract newServer(b *backend) *mcp.Server out of main() (pure refactor, no behavior change) so tests can stand up the real registered server over an in-memory MCP transport, and add main_mcp_test.go covering every bullet in the issue through actual tool calls: create_media_buy with/without creative assignments, pending_creatives -> active via sync_creatives and update_media_buy, cancel + double-cancel (asserting the specific NOT_CANCELLABLE code), list_creatives filtering by creative_id and format_id, delivery simulation/reporting via comply_test_controller + get_media_buy_delivery, and the seed_product/force_create_media_buy_arm custom scenarios end to end. Verified the new tests catch real regressions, not just compile: breaking forceAccountStatus's status assignment, the double-cancel guard, and simulateDelivery's spend accumulation each failed the corresponding new test (the double-cancel and delivery breaks also exposed that the existing tests check IsError/spend but not the specific error code the new MCP test asserts), then restored and reconfirmed all tests pass. Closes #140. --- .../seller-agent/cmd/seller-agent/main.go | 300 ++++++------- .../cmd/seller-agent/main_mcp_test.go | 402 ++++++++++++++++++ .../cmd/seller-agent/main_test.go | 32 ++ 3 files changed, 588 insertions(+), 146 deletions(-) create mode 100644 reference/seller-agent/cmd/seller-agent/main_mcp_test.go diff --git a/reference/seller-agent/cmd/seller-agent/main.go b/reference/seller-agent/cmd/seller-agent/main.go index 36558f2f..ca363ab9 100644 --- a/reference/seller-agent/cmd/seller-agent/main.go +++ b/reference/seller-agent/cmd/seller-agent/main.go @@ -1256,170 +1256,178 @@ func actionNotAllowedResult(action adcp.MediaBuyValidAction, reason, recovery st return result, outAny, err, true } -func main() { - b := &backend{ - accounts: make(map[string]*adcp.AccountResult), - products: baseProducts(), - mediaBuys: make(map[string]*adcp.MediaBuyData), - creatives: make(map[string]*creativeRecord), - delivery: make(map[string]*deliveryState), - } - - log.Fatal(adcp.Serve(func() *mcp.Server { - server := mcp.NewServer(&mcp.Implementation{Name: "reference-seller", Version: "1.0.0"}, nil) - - adcp.Register(server, adcp.Config{ - Sandbox: true, - IdempotencyReplayTTL: 24 * time.Hour, - Capabilities: &adcp.CapabilitiesData{ - Account: &adcp.AccountCapabilities{SupportedBilling: []string{"operator", "agent"}, Sandbox: boolPtr(true)}, - MediaBuy: &adcp.MediaBuyCapabilities{ - SupportedPricingModels: []string{"cpm", "cpcv"}, - Portfolio: &adcp.PortfolioCaps{PublisherDomains: []string{"example.com"}, PrimaryChannels: []string{"display", "olv"}}, - }, - Creative: &adcp.CreativeCapabilities{HasCreativeLibrary: boolPtr(true), SupportsCompliance: boolPtr(true)}, - ComplianceTesting: &adcp.ComplianceTestingCapabilities{Scenarios: []string{ - "force_account_status", "force_media_buy_status", "force_creative_status", - "simulate_delivery", "simulate_budget_spend", - }}, - }, - ResolveAccount: func(_ context.Context, ref adcp.AccountReference) (any, error) { - b.mu.RLock() - defer b.mu.RUnlock() - domain := "" - if ref.Brand != nil { - domain = ref.Brand.Domain - } - id := fmt.Sprintf("acct-%s-%s", domain, ref.Operator) - if acct, ok := b.accounts[id]; ok { - return acct, nil - } - return &adcp.AccountResult{AccountID: id, Brand: ref.Brand, Operator: ref.Operator, Action: "existing", Status: "active"}, nil +// newServer wires the reference seller's MCP tools onto a fresh server backed +// by b. Extracted from main so tests can stand up the exact same tool +// registration (including comply_test_controller) over an in-memory MCP +// transport, instead of only exercising backend methods directly. +func newServer(b *backend) *mcp.Server { + server := mcp.NewServer(&mcp.Implementation{Name: "reference-seller", Version: "1.0.0"}, nil) + + adcp.Register(server, adcp.Config{ + Sandbox: true, + IdempotencyReplayTTL: 24 * time.Hour, + Capabilities: &adcp.CapabilitiesData{ + Account: &adcp.AccountCapabilities{SupportedBilling: []string{"operator", "agent"}, Sandbox: boolPtr(true)}, + MediaBuy: &adcp.MediaBuyCapabilities{ + SupportedPricingModels: []string{"cpm", "cpcv"}, + Portfolio: &adcp.PortfolioCaps{PublisherDomains: []string{"example.com"}, PrimaryChannels: []string{"display", "olv"}}, }, - SyncAccounts: func(_ context.Context, input *adcp.SyncAccountsRequest) ([]adcp.AccountResult, error) { - b.mu.Lock() - defer b.mu.Unlock() - results := make([]adcp.AccountResult, 0, len(input.Accounts)) - for _, acct := range input.Accounts { - domain := "unknown" - if acct.Brand != nil { - domain = acct.Brand.Domain - } - id := fmt.Sprintf("acct-%s-%s", domain, acct.Operator) - result := adcp.AccountResult{AccountID: id, Brand: acct.Brand, Operator: acct.Operator, Action: "created", Status: "active"} - if existing, ok := b.accounts[id]; ok { - result.Action = "updated" - result.Status = existing.Status - } - b.accounts[id] = &result - results = append(results, result) + Creative: &adcp.CreativeCapabilities{HasCreativeLibrary: boolPtr(true), SupportsCompliance: boolPtr(true)}, + ComplianceTesting: &adcp.ComplianceTestingCapabilities{Scenarios: []string{ + "force_account_status", "force_media_buy_status", "force_creative_status", + "simulate_delivery", "simulate_budget_spend", + }}, + }, + ResolveAccount: func(_ context.Context, ref adcp.AccountReference) (any, error) { + b.mu.RLock() + defer b.mu.RUnlock() + domain := "" + if ref.Brand != nil { + domain = ref.Brand.Domain + } + id := fmt.Sprintf("acct-%s-%s", domain, ref.Operator) + if acct, ok := b.accounts[id]; ok { + return acct, nil + } + return &adcp.AccountResult{AccountID: id, Brand: ref.Brand, Operator: ref.Operator, Action: "existing", Status: "active"}, nil + }, + SyncAccounts: func(_ context.Context, input *adcp.SyncAccountsRequest) ([]adcp.AccountResult, error) { + b.mu.Lock() + defer b.mu.Unlock() + results := make([]adcp.AccountResult, 0, len(input.Accounts)) + for _, acct := range input.Accounts { + domain := "unknown" + if acct.Brand != nil { + domain = acct.Brand.Domain } - return results, nil - }, - SyncGovernance: func(_ context.Context, input *adcp.SyncGovernanceRequest) ([]adcp.GovernanceResult, error) { - results := make([]adcp.GovernanceResult, 0, len(input.Accounts)) - for _, acct := range input.Accounts { - govAcct := acct.Account - if govAcct == nil { - govAcct = &adcp.GovernanceAccount{Brand: acct.Brand, Operator: acct.Operator} - } - results = append(results, adcp.GovernanceResult{Account: govAcct, Status: "synced", GovernanceAgents: acct.GovernanceAgents}) + id := fmt.Sprintf("acct-%s-%s", domain, acct.Operator) + result := adcp.AccountResult{AccountID: id, Brand: acct.Brand, Operator: acct.Operator, Action: "created", Status: "active"} + if existing, ok := b.accounts[id]; ok { + result.Action = "updated" + result.Status = existing.Status } - return results, nil - }, - GetProducts: func(_ context.Context, _ any, input *adcp.GetProductsRequest) (*adcp.ProductsData, error) { - b.mu.RLock() - defer b.mu.RUnlock() - products := make([]adcp.Product, 0, len(b.products)) - for _, product := range b.products { - products = append(products, *product) + b.accounts[id] = &result + results = append(results, result) + } + return results, nil + }, + SyncGovernance: func(_ context.Context, input *adcp.SyncGovernanceRequest) ([]adcp.GovernanceResult, error) { + results := make([]adcp.GovernanceResult, 0, len(input.Accounts)) + for _, acct := range input.Accounts { + govAcct := acct.Account + if govAcct == nil { + govAcct = &adcp.GovernanceAccount{Brand: acct.Brand, Operator: acct.Operator} } - sortProducts(products, input.Brief) - data := &adcp.ProductsData{Products: products, CacheScope: "public"} - if input.BuyingMode == "refine" && len(input.Refine) > 0 { - applied := make([]adcp.GetProductsRefinementAppliedItem, 0, len(input.Refine)) - for _, ref := range input.Refine { - item := adcp.GetProductsRefinementAppliedItem{ - Scope: ref.Scope, - Status: "applied", - ProductID: ref.ProductID, - ProposalID: ref.ProposalID, - } - applied = append(applied, item) + results = append(results, adcp.GovernanceResult{Account: govAcct, Status: "synced", GovernanceAgents: acct.GovernanceAgents}) + } + return results, nil + }, + GetProducts: func(_ context.Context, _ any, input *adcp.GetProductsRequest) (*adcp.ProductsData, error) { + b.mu.RLock() + defer b.mu.RUnlock() + products := make([]adcp.Product, 0, len(b.products)) + for _, product := range b.products { + products = append(products, *product) + } + sortProducts(products, input.Brief) + data := &adcp.ProductsData{Products: products, CacheScope: "public"} + if input.BuyingMode == "refine" && len(input.Refine) > 0 { + applied := make([]adcp.GetProductsRefinementAppliedItem, 0, len(input.Refine)) + for _, ref := range input.Refine { + item := adcp.GetProductsRefinementAppliedItem{ + Scope: ref.Scope, + Status: "applied", + ProductID: ref.ProductID, + ProposalID: ref.ProposalID, } - return &adcp.ProductsData{Products: products, RefinementApplied: applied, CacheScope: "public"}, nil + applied = append(applied, item) } - return data, nil - }, - CreateMediaBuy: func(_ context.Context, _ any, input *adcp.CreateMediaBuyRequest) (adcp.CreateMediaBuyResponse, error) { - return b.createMediaBuyResponse(input) - }, - GetMediaBuys: func(_ context.Context, _ any, input *adcp.GetMediaBuysRequest) (*adcp.GetMediaBuysResponse, error) { - b.mu.RLock() - defer b.mu.RUnlock() - buys := make([]adcp.MediaBuyData, 0) - if len(input.MediaBuyIDs) > 0 { - for _, id := range input.MediaBuyIDs { - if buy, ok := b.mediaBuys[id]; ok { - item := *buy - b.decorateMediaBuySnapshot(&item) - buys = append(buys, item) - } - } - } else { - for _, buy := range b.mediaBuys { + return &adcp.ProductsData{Products: products, RefinementApplied: applied, CacheScope: "public"}, nil + } + return data, nil + }, + CreateMediaBuy: func(_ context.Context, _ any, input *adcp.CreateMediaBuyRequest) (adcp.CreateMediaBuyResponse, error) { + return b.createMediaBuyResponse(input) + }, + GetMediaBuys: func(_ context.Context, _ any, input *adcp.GetMediaBuysRequest) (*adcp.GetMediaBuysResponse, error) { + b.mu.RLock() + defer b.mu.RUnlock() + buys := make([]adcp.MediaBuyData, 0) + if len(input.MediaBuyIDs) > 0 { + for _, id := range input.MediaBuyIDs { + if buy, ok := b.mediaBuys[id]; ok { item := *buy b.decorateMediaBuySnapshot(&item) buys = append(buys, item) } } - return &adcp.GetMediaBuysResponse{MediaBuys: buys}, nil - }, - ListCreativeFormats: func(_ context.Context, input *adcp.ListCreativeFormatsRequest) ([]adcp.CreativeFormat, error) { - if len(input.FormatIDs) > 0 { - filtered := make([]adcp.CreativeFormat, 0, len(input.FormatIDs)) - for _, want := range input.FormatIDs { - for _, format := range formats { - if format.FormatID.AgentURL == want.AgentURL && format.FormatID.ID == want.ID { - filtered = append(filtered, format) - } + } else { + for _, buy := range b.mediaBuys { + item := *buy + b.decorateMediaBuySnapshot(&item) + buys = append(buys, item) + } + } + return &adcp.GetMediaBuysResponse{MediaBuys: buys}, nil + }, + ListCreativeFormats: func(_ context.Context, input *adcp.ListCreativeFormatsRequest) ([]adcp.CreativeFormat, error) { + if len(input.FormatIDs) > 0 { + filtered := make([]adcp.CreativeFormat, 0, len(input.FormatIDs)) + for _, want := range input.FormatIDs { + for _, format := range formats { + if format.FormatID.AgentURL == want.AgentURL && format.FormatID.ID == want.ID { + filtered = append(filtered, format) } } - return filtered, nil } - return formats, nil - }, - SyncCreatives: func(_ context.Context, input *adcp.SyncCreativesRequest) ([]adcp.CreativeResult, error) { - return b.syncCreatives(input) - }, - GetDelivery: func(_ context.Context, _ any, input *adcp.GetMediaBuyDeliveryRequest) (*adcp.DeliveryData, error) { - return b.getDelivery(input) - }, + return filtered, nil + } + return formats, nil + }, + SyncCreatives: func(_ context.Context, input *adcp.SyncCreativesRequest) ([]adcp.CreativeResult, error) { + return b.syncCreatives(input) + }, + GetDelivery: func(_ context.Context, _ any, input *adcp.GetMediaBuyDeliveryRequest) (*adcp.DeliveryData, error) { + return b.getDelivery(input) + }, + }) + + adcp.AddTool(server, "update_media_buy", "Update a media buy", + func(ctx context.Context, req *mcp.CallToolRequest, input adcp.UpdateMediaBuyRequest) (*mcp.CallToolResult, any, error) { + return b.updateMediaBuy(input) }) - adcp.AddTool(server, "update_media_buy", "Update a media buy", - func(ctx context.Context, req *mcp.CallToolRequest, input adcp.UpdateMediaBuyRequest) (*mcp.CallToolResult, any, error) { - return b.updateMediaBuy(input) - }) + adcp.AddTool(server, "list_creatives", "List synced creatives", + func(ctx context.Context, req *mcp.CallToolRequest, input adcp.ListCreativesRequest) (*mcp.CallToolResult, any, error) { + return b.listCreatives(input) + }) - adcp.AddTool(server, "list_creatives", "List synced creatives", - func(ctx context.Context, req *mcp.CallToolRequest, input adcp.ListCreativesRequest) (*mcp.CallToolResult, any, error) { - return b.listCreatives(input) - }) + // Test controller — sandbox only. Do not register in production. + if os.Getenv("ADCP_SANDBOX") != "false" { + adcp.RegisterTestController(server, &adcp.TestControllerStore{ + CustomScenarios: customScenarios, + ForceAccountStatus: b.forceAccountStatus, + ForceMediaBuyStatus: b.forceMediaBuyStatus, + ForceCreativeStatus: b.forceCreativeStatus, + SimulateDelivery: b.simulateDelivery, + SimulateBudgetSpend: b.simulateBudgetSpend, + CustomScenario: b.handleCustomScenario, + }) + } - // Test controller — sandbox only. Do not register in production. - if os.Getenv("ADCP_SANDBOX") != "false" { - adcp.RegisterTestController(server, &adcp.TestControllerStore{ - CustomScenarios: customScenarios, - ForceAccountStatus: b.forceAccountStatus, - ForceMediaBuyStatus: b.forceMediaBuyStatus, - ForceCreativeStatus: b.forceCreativeStatus, - SimulateDelivery: b.simulateDelivery, - SimulateBudgetSpend: b.simulateBudgetSpend, - CustomScenario: b.handleCustomScenario, - }) - } + return server +} + +func main() { + b := &backend{ + accounts: make(map[string]*adcp.AccountResult), + products: baseProducts(), + mediaBuys: make(map[string]*adcp.MediaBuyData), + creatives: make(map[string]*creativeRecord), + delivery: make(map[string]*deliveryState), + } - return server + log.Fatal(adcp.Serve(func() *mcp.Server { + return newServer(b) })) } diff --git a/reference/seller-agent/cmd/seller-agent/main_mcp_test.go b/reference/seller-agent/cmd/seller-agent/main_mcp_test.go new file mode 100644 index 00000000..91a687cc --- /dev/null +++ b/reference/seller-agent/cmd/seller-agent/main_mcp_test.go @@ -0,0 +1,402 @@ +package main + +// MCP-level tests exercise the seller's tools through the real registered +// MCP server (newServer, which wires the same adcp.Register/adcp.AddTool/ +// adcp.RegisterTestController calls main() uses to serve requests) over an +// in-memory transport. +// +// The unit tests in main_test.go call backend methods (b.createMediaBuy, +// b.updateMediaBuy, ...) directly, which exercises the state machine but +// bypasses tool registration and JSON request/response marshaling entirely. +// These tests instead dispatch by tool name with JSON-shaped arguments the +// way a real MCP client — including the npm storyboard runner — would, so a +// regression in which tools are registered, their names, or their request/ +// response field mapping is also caught by `go test ./...` and not only by +// the storyboard. + +import ( + "context" + "encoding/json" + "testing" + + "github.com/modelcontextprotocol/go-sdk/mcp" +) + +func newMCPTestSession(t *testing.T) *mcp.ClientSession { + t.Helper() + return newMCPTestSessionForBackend(t, newTestBackend()) +} + +func newMCPTestSessionForBackend(t *testing.T, b *backend) *mcp.ClientSession { + t.Helper() + server := newServer(b) + + clientTransport, serverTransport := mcp.NewInMemoryTransports() + ctx := context.Background() + + serverSession, err := server.Connect(ctx, serverTransport, nil) + if err != nil { + t.Fatalf("server connect: %v", err) + } + t.Cleanup(func() { _ = serverSession.Close() }) + + client := mcp.NewClient(&mcp.Implementation{Name: "seller-agent-test-client", Version: "v0.0.1"}, nil) + clientSession, err := client.Connect(ctx, clientTransport, nil) + if err != nil { + t.Fatalf("client connect: %v", err) + } + t.Cleanup(func() { _ = clientSession.Close() }) + + return clientSession +} + +func callTool(t *testing.T, session *mcp.ClientSession, name string, args map[string]any) *mcp.CallToolResult { + t.Helper() + result, err := session.CallTool(context.Background(), &mcp.CallToolParams{Name: name, Arguments: args}) + if err != nil { + t.Fatalf("call tool %s: %v", name, err) + } + if result == nil { + t.Fatalf("call tool %s: nil result", name) + } + return result +} + +func structuredMap(t *testing.T, result *mcp.CallToolResult) map[string]any { + t.Helper() + raw, err := json.Marshal(result.StructuredContent) + if err != nil { + t.Fatalf("marshal structured content: %v", err) + } + var m map[string]any + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("unmarshal structured content: %v", err) + } + return m +} + +// --- create_media_buy over MCP: with and without creative assignments --- + +func TestMCP_CreateMediaBuy_WithoutCreatives(t *testing.T) { + session := newMCPTestSession(t) + + result := callTool(t, session, "create_media_buy", map[string]any{ + "packages": []any{ + map[string]any{"product_id": "premium-display", "pricing_option_id": "pd-cpm-15", "budget": 500.0}, + }, + }) + if result.IsError { + t.Fatalf("create_media_buy returned error result: %s", mustMarshal(t, result.StructuredContent)) + } + wire := structuredMap(t, result) + if wire["status"] != "pending_creatives" { + t.Errorf("want status pending_creatives, got %v", wire["status"]) + } + if id, _ := wire["media_buy_id"].(string); id == "" { + t.Error("expected non-empty media_buy_id") + } +} + +func TestMCP_CreateMediaBuy_WithCreatives(t *testing.T) { + session := newMCPTestSession(t) + + result := callTool(t, session, "create_media_buy", map[string]any{ + "packages": []any{ + map[string]any{ + "product_id": "premium-display", "pricing_option_id": "pd-cpm-15", "budget": 500.0, + "creative_assignments": []any{map[string]any{"creative_id": "cr-mcp-initial"}}, + }, + }, + }) + if result.IsError { + t.Fatalf("create_media_buy returned error result: %s", mustMarshal(t, result.StructuredContent)) + } + wire := structuredMap(t, result) + if wire["status"] != "active" { + t.Errorf("want status active when creatives supplied at create, got %v", wire["status"]) + } +} + +// --- pending_creatives -> active over MCP, via sync_creatives and update_media_buy --- + +func TestMCP_PendingCreativesToActive_ViaSyncCreatives(t *testing.T) { + session := newMCPTestSession(t) + + created := structuredMap(t, callTool(t, session, "create_media_buy", map[string]any{ + "packages": []any{map[string]any{"product_id": "premium-display", "pricing_option_id": "pd-cpm-15", "budget": 500.0}}, + })) + mediaBuyID, _ := created["media_buy_id"].(string) + pkgID := firstPackageID(t, created) + + syncResult := callTool(t, session, "sync_creatives", map[string]any{ + "creatives": []any{map[string]any{"creative_id": "cr-mcp-sync", "name": "Banner"}}, + "assignments": []any{map[string]any{"creative_id": "cr-mcp-sync", "package_id": pkgID}}, + }) + if syncResult.IsError { + t.Fatalf("sync_creatives returned error result: %s", mustMarshal(t, syncResult.StructuredContent)) + } + + getResult := callTool(t, session, "get_media_buys", map[string]any{"media_buy_ids": []any{mediaBuyID}}) + if getResult.IsError { + t.Fatalf("get_media_buys returned error result: %s", mustMarshal(t, getResult.StructuredContent)) + } + if status := firstMediaBuyStatus(t, structuredMap(t, getResult)); status != "active" { + t.Errorf("want active after sync_creatives with assignment, got %v", status) + } +} + +func TestMCP_PendingCreativesToActive_ViaUpdateMediaBuy(t *testing.T) { + session := newMCPTestSession(t) + + created := structuredMap(t, callTool(t, session, "create_media_buy", map[string]any{ + "packages": []any{map[string]any{"product_id": "premium-display", "pricing_option_id": "pd-cpm-15", "budget": 500.0}}, + })) + mediaBuyID, _ := created["media_buy_id"].(string) + pkgID := firstPackageID(t, created) + + updateResult := callTool(t, session, "update_media_buy", map[string]any{ + "media_buy_id": mediaBuyID, + "packages": []any{ + map[string]any{"package_id": pkgID, "creative_assignments": []any{map[string]any{"creative_id": "cr-mcp-upd"}}}, + }, + }) + if updateResult.IsError { + t.Fatalf("update_media_buy returned error result: %s", mustMarshal(t, updateResult.StructuredContent)) + } + wire := structuredMap(t, updateResult) + if wire["status"] != "active" { + t.Errorf("want active after update_media_buy with creative assignment, got %v", wire["status"]) + } +} + +// --- cancellation and double-cancel over MCP --- + +func TestMCP_CancelAndDoubleCancel(t *testing.T) { + session := newMCPTestSession(t) + + created := structuredMap(t, callTool(t, session, "create_media_buy", map[string]any{ + "packages": []any{map[string]any{"product_id": "premium-display", "pricing_option_id": "pd-cpm-15", "budget": 500.0}}, + })) + mediaBuyID, _ := created["media_buy_id"].(string) + cancelArgs := map[string]any{"media_buy_id": mediaBuyID, "canceled": true, "cancellation_reason": "budget_cut"} + + first := callTool(t, session, "update_media_buy", cancelArgs) + if first.IsError { + t.Fatalf("first cancel returned error result: %s", mustMarshal(t, first.StructuredContent)) + } + if wire := structuredMap(t, first); wire["status"] != "canceled" { + t.Errorf("want canceled after first cancel, got %v", wire["status"]) + } + + second := callTool(t, session, "update_media_buy", cancelArgs) + if !second.IsError { + t.Fatal("expected error result on double-cancel via MCP, got success") + } + if code := resultErrorCode(t, second); code != "NOT_CANCELLABLE" { + t.Errorf("want NOT_CANCELLABLE on double-cancel, got %q", code) + } +} + +// --- list_creatives filtering over MCP --- + +func TestMCP_ListCreatives_FilterByCreativeIDAndFormatID(t *testing.T) { + session := newMCPTestSession(t) + + fmtA := map[string]any{"agent_url": "http://test", "id": "banner-300x250"} + fmtB := map[string]any{"agent_url": "http://test", "id": "video-15s"} + + syncResult := callTool(t, session, "sync_creatives", map[string]any{ + "creatives": []any{ + map[string]any{"creative_id": "cr-mcp-a", "format_id": fmtA}, + map[string]any{"creative_id": "cr-mcp-b", "format_id": fmtB}, + }, + }) + if syncResult.IsError { + t.Fatalf("sync_creatives returned error result: %s", mustMarshal(t, syncResult.StructuredContent)) + } + + byID := structuredMap(t, callTool(t, session, "list_creatives", map[string]any{ + "filters": map[string]any{"creative_ids": []any{"cr-mcp-a"}}, + })) + idItems, _ := byID["creatives"].([]any) + if len(idItems) != 1 { + t.Fatalf("want 1 creative filtered by creative_ids, got %d: %#v", len(idItems), idItems) + } + if first, ok := idItems[0].(map[string]any); !ok || first["creative_id"] != "cr-mcp-a" { + t.Errorf("want cr-mcp-a, got %#v", idItems[0]) + } + + byFormat := structuredMap(t, callTool(t, session, "list_creatives", map[string]any{ + "filters": map[string]any{"format_ids": []any{fmtB}}, + })) + fmtItems, _ := byFormat["creatives"].([]any) + if len(fmtItems) != 1 { + t.Fatalf("want 1 creative filtered by format_ids, got %d: %#v", len(fmtItems), fmtItems) + } + if first, ok := fmtItems[0].(map[string]any); !ok || first["creative_id"] != "cr-mcp-b" { + t.Errorf("want cr-mcp-b, got %#v", fmtItems[0]) + } +} + +// --- delivery simulation/reporting over MCP: comply_test_controller then get_media_buy_delivery --- + +func TestMCP_DeliverySimulationAndReporting(t *testing.T) { + session := newMCPTestSession(t) + + created := structuredMap(t, callTool(t, session, "create_media_buy", map[string]any{ + "packages": []any{ + map[string]any{ + "product_id": "premium-display", "pricing_option_id": "pd-cpm-15", "budget": 1000.0, + "creative_assignments": []any{map[string]any{"creative_id": "cr-mcp-delivery"}}, + }, + }, + })) + mediaBuyID, _ := created["media_buy_id"].(string) + + simResult := callTool(t, session, "comply_test_controller", map[string]any{ + "scenario": "simulate_delivery", + "params": map[string]any{ + "media_buy_id": mediaBuyID, + "impressions": 1000.0, + "clicks": 50.0, + "reported_spend": map[string]any{"amount": 15.0, "currency": "USD"}, + }, + }) + if simResult.IsError { + t.Fatalf("simulate_delivery returned error result: %s", mustMarshal(t, simResult.StructuredContent)) + } + + deliveryResult := callTool(t, session, "get_media_buy_delivery", map[string]any{"media_buy_ids": []any{mediaBuyID}}) + if deliveryResult.IsError { + t.Fatalf("get_media_buy_delivery returned error result: %s", mustMarshal(t, deliveryResult.StructuredContent)) + } + wire := structuredMap(t, deliveryResult) + deliveries, _ := wire["media_buy_deliveries"].([]any) + if len(deliveries) != 1 { + t.Fatalf("want 1 media buy delivery, got %d", len(deliveries)) + } + entry, ok := deliveries[0].(map[string]any) + if !ok { + t.Fatalf("expected delivery entry object, got %#v", deliveries[0]) + } + totals, ok := entry["totals"].(map[string]any) + if !ok { + t.Fatalf("expected totals object in delivery response, got %#v", entry["totals"]) + } + if impressions, _ := totals["impressions"].(float64); impressions != 1000 { + t.Errorf("want 1000 impressions, got %v", totals["impressions"]) + } + if spend, _ := totals["spend"].(float64); spend != 15 { + t.Errorf("want 15 spend, got %v", totals["spend"]) + } +} + +// --- custom comply_test_controller scenarios over MCP --- + +func TestMCP_ComplyTestController_SeedProduct(t *testing.T) { + session := newMCPTestSession(t) + + seedResult := callTool(t, session, "comply_test_controller", map[string]any{ + "scenario": "seed_product", + "params": map[string]any{ + "product_id": "mcp-seeded-product", + "fixture": map[string]any{"channels": []any{"video"}, "delivery_type": "non_guaranteed"}, + }, + }) + if seedResult.IsError { + t.Fatalf("seed_product scenario returned error result: %s", mustMarshal(t, seedResult.StructuredContent)) + } + + productsWire := structuredMap(t, callTool(t, session, "get_products", map[string]any{})) + products, _ := productsWire["products"].([]any) + found := false + for _, p := range products { + m, ok := p.(map[string]any) + if ok && m["product_id"] == "mcp-seeded-product" { + found = true + if m["delivery_type"] != "non_guaranteed" { + t.Errorf("want delivery_type non_guaranteed, got %v", m["delivery_type"]) + } + } + } + if !found { + t.Fatal("seeded product not found in get_products response") + } +} + +func TestMCP_ComplyTestController_ForceCreateMediaBuyArm(t *testing.T) { + session := newMCPTestSession(t) + + armResult := callTool(t, session, "comply_test_controller", map[string]any{ + "scenario": "force_create_media_buy_arm", + "params": map[string]any{"arm": "submitted", "task_id": "mcp-task-1", "message": "queued for review"}, + }) + if armResult.IsError { + t.Fatalf("force_create_media_buy_arm returned error result: %s", mustMarshal(t, armResult.StructuredContent)) + } + + createResult := callTool(t, session, "create_media_buy", map[string]any{ + "packages": []any{map[string]any{"product_id": "premium-display", "pricing_option_id": "pd-cpm-15", "budget": 500.0}}, + }) + if createResult.IsError { + t.Fatalf("create_media_buy after forced arm returned error result: %s", mustMarshal(t, createResult.StructuredContent)) + } + wire := structuredMap(t, createResult) + if wire["status"] != "submitted" { + t.Errorf("want submitted status after forced arm, got %v", wire["status"]) + } + if wire["task_id"] != "mcp-task-1" { + t.Errorf("want task_id mcp-task-1, got %v", wire["task_id"]) + } +} + +func TestMCP_ComplyTestController_UnknownScenario(t *testing.T) { + session := newMCPTestSession(t) + + result := callTool(t, session, "comply_test_controller", map[string]any{"scenario": "totally_unsupported_scenario"}) + if !result.IsError { + t.Fatal("expected error result for unknown scenario") + } +} + +// --- helpers --- + +func firstPackageID(t *testing.T, wire map[string]any) string { + t.Helper() + packages, ok := wire["packages"].([]any) + if !ok || len(packages) == 0 { + t.Fatalf("expected at least 1 package in response: %#v", wire["packages"]) + } + pkg, ok := packages[0].(map[string]any) + if !ok { + t.Fatalf("expected package object, got %#v", packages[0]) + } + id, _ := pkg["package_id"].(string) + if id == "" { + t.Fatal("expected non-empty package_id") + } + return id +} + +func firstMediaBuyStatus(t *testing.T, wire map[string]any) string { + t.Helper() + buys, ok := wire["media_buys"].([]any) + if !ok || len(buys) == 0 { + t.Fatalf("expected at least 1 media buy in get_media_buys response: %#v", wire["media_buys"]) + } + buy, ok := buys[0].(map[string]any) + if !ok { + t.Fatalf("expected media buy object, got %#v", buys[0]) + } + status, _ := buy["status"].(string) + return status +} + +func mustMarshal(t *testing.T, v any) string { + t.Helper() + b, err := json.Marshal(v) + if err != nil { + t.Fatalf("marshal: %v", err) + } + return string(b) +} diff --git a/reference/seller-agent/cmd/seller-agent/main_test.go b/reference/seller-agent/cmd/seller-agent/main_test.go index 931fdfac..371cd34b 100644 --- a/reference/seller-agent/cmd/seller-agent/main_test.go +++ b/reference/seller-agent/cmd/seller-agent/main_test.go @@ -922,6 +922,38 @@ func TestForceMediaBuyStatus_NotFound(t *testing.T) { } } +// --- ForceAccountStatus --- + +func TestForceAccountStatus(t *testing.T) { + b := newTestBackend() + b.accounts["acct-1"] = &adcp.AccountResult{AccountID: "acct-1", Status: "active"} + + tr, err := b.forceAccountStatus("acct-1", "suspended") + if err != nil { + t.Fatalf("forceAccountStatus: %v", err) + } + if !tr.Success { + t.Error("expected Success=true") + } + if tr.PreviousState != "active" { + t.Errorf("want previous state active, got %s", tr.PreviousState) + } + if tr.CurrentState != "suspended" { + t.Errorf("want current state suspended, got %s", tr.CurrentState) + } + if b.accounts["acct-1"].Status != "suspended" { + t.Errorf("forceAccountStatus did not persist new status, got %s", b.accounts["acct-1"].Status) + } +} + +func TestForceAccountStatus_NotFound(t *testing.T) { + b := newTestBackend() + _, err := b.forceAccountStatus("nonexistent-account", "suspended") + if err == nil { + t.Error("expected error for unknown account ID") + } +} + func TestValidActions_UnknownStatusFailsClosed(t *testing.T) { if got := validActions("future_status"); len(got) != 0 { t.Fatalf("unknown media buy status should expose no valid actions, got %#v", got)