Appearance
Mock CRM API (HTTP-level)
How Hamming tests run end-to-end against a deterministic CRM substitute without touching real AutoOps. Complement to Tool Call Mocks, not a replacement — the two operate at different layers and serve different test shapes.
What this is
A WireMock instance hosted in the toolkit at apps/api-mocker/ that impersonates the AutoOps HTTP API. When the AutoOps client in avoca-next is pointed at it (via AUTOOPS_BASE_URL), every request the client would normally send to https://api.autoops.com/v1 lands in WireMock instead. WireMock returns canned responses (defined as JSON stub mappings) and records every request in a journal we can inspect after the call.
The agent's reasoning path is real (real LLM, real prompt, real Vapi assistant, real PSTN). The CRM is fake.
Why a second mock layer
Avoca already has Tool Call Mocks — a per-tool, per-test-case mock at the dispatcher level. The two layers exist because they answer different questions:
| Layer | Operates at | Best for |
|---|---|---|
| Tool Call Mocks | Tool dispatcher (before the CRM client is ever called) | Deterministic per-test-case regression. Fine-grained "for test case X, tool Y returns Z." Lives in Supabase, configured per test case. |
| Mock CRM API | HTTP transport (CRM client still runs in full) | End-to-end flows where the CRM client's own logic matters: retry, idempotency, pagination, cross-shop fan-out, post-call workflow. Lives in WireMock, configured globally per endpoint. |
If your goal is to validate "the agent calls autoOpsConfirmAppointment with the right args" → use Tool Call Mocks. If your goal is to validate "a real call lands a booking through the post-call workflow and the AutoOps book POST is shaped correctly" → use Mock CRM API.
Both can be active at once. They don't conflict because they intercept at different points.
Current architecture
The redirect happens at two points, both in avoca-next, both currently hardcoded on the test/mock-autoops-southpark throwaway branch:
Verified observations
Layer 1: URL substitution. apps/web/lib/autoops/autoops.ts:20 defines AUTOOPS_BASE_URL. On the test branch it's hardcoded to https://api-mocker.dft.dawn. Every AutoOpsClient instance reads this constant for its requests. One value, global to the process.
Layer 2: Team config short-circuit. apps/web/lib/supabase/autoops.ts (getAutoOpsConfig and getAutoOpsConfigByClientId) normally reads autoops_team_configs + autoops_credentials from Supabase. On the test branch, both functions short-circuit for a hardcoded list of test team IDs / clientIds, returning canned configs without touching the DB or decryptApiKey.
Both are needed. With only Layer 1, the request would be routed to the mock but getAutoOpsConfig would return null (no DB row) and the tool handler would bail before issuing it. With only Layer 2, the config exists but requests still target real AutoOps.
Why test teams use real AutoOps clientIds
The hardcoded configs return the REAL AutoOps clientIds from shop-routing.json:
| teamId | shop | clientId |
|---|---|---|
| 2970 | EAS South Park | cl_e019c08b5b3a49b5b7a990e67ceb3e6f |
| 2977 | EAS Platte Canyon | cl_25f8cdcc61564b36acdfee2b2beb0457 |
| 2976 | EAS Chatfield | cl_b5b5123b262a4ea8a68ababfc768e662 |
Reason: shop-routing.json keys cross-shop sibling lookups by AutoOps clientId. Using fabricated MOCK_CLIENT_001 style values would make SHOP_ROUTING[callingShopClientId] return undefined → no siblings → no cross-shop fan-out, defeating one of the main flows we want to exercise.
Safe because WireMock matches /booking-flow/[^/]+/availability (any clientId) and AUTOOPS_BASE_URL points at WireMock. No request reaches real AutoOps regardless of which clientId is in the URL.
Generalization: applying this to a real enterprise
The current setup uses hardcoded shortcuts on a throwaway branch because the test teams (2970/2977/2976) exist only for testing. If you wanted to give a real enterprise (e.g., a production EAS team handling real customer calls) the same end-to-end mock-CRM testing capability, here is what would have to change.
The constraint
Real enterprises have real customers. The redirect mechanism cannot be team-global. A test call to the team's designated test phone must hit the mock; a real customer's call to the team's main line must hit real AutoOps. Same team, different routing.
What would need to change
AUTOOPS_BASE_URLcannot be hardcoded. It would become a per-request decision: "for this call's context, where should the AutoOps client point?" Two viable implementation shapes:- Construction-time flag.
new AutoOpsClient({ clientId, apiKey, baseUrl })takes abaseUrlparameter. The call site computes it from call context. - Env-var driven.
AUTOOPS_BASE_URL = process.env.AUTOOPS_BASE_URL ?? 'https://api.autoops.com/v1'. Per-call routing requires a different mechanism (one server can only have one env var).
Construction-time is cleaner because the same process can serve both test and prod calls.
- Construction-time flag.
getAutoOpsConfigshort-circuit becomes call-context-based, not teamId-based. Instead of "if teamId is in the test list, return mock config," the check becomes "if this call is test traffic, return a mock config (with the real clientId for routing-fidelity)."The detection signal already exists:
isHammingTestCall(customerPhone)and the?isTest=truequery param baked into test-phone server URLs bybuildTestPhoneServerUrl. Tool Call Mocks uses these signals. Mock CRM API would use the same ones.apiKeyresolution branches on the same signal. Real calls decrypt the real API key (existingdecryptApiKeypath); test calls return'mock-key'without decrypting. This means real and test calls can coexist for the same team — same DB row, two routing outcomes.Cross-shop routing already works. Because we use real clientIds,
shop-routing.jsonworks identically for test and real calls. No change needed here.Post-call workflow gating.
TEST_PHONE_POST_CALLalready exists as a gate (provision-test-phone.ts). The same gate would prevent post-call Email/SMS to fake mock customers.
Architectural shape
The general pattern is: detect test traffic once, branch all downstream CRM behavior on that single signal. This is the same shape Tool Call Mocks already implements at the tool layer. Mock CRM API would implement it at the HTTP layer:
The branch point is the single line where you decide "this call is test." Everything downstream (clientId, apiKey, baseUrl, post-call gating) flows from that decision.
Why this works
The throwaway branch is doing all of this with hardcoded constants because the test teams are isolated. The generalization makes the same logic dynamic: same data flow, different inputs. No new infrastructure required (WireMock, shop-routing, test-phone detection all already exist).
The work would be a small refactor in two files (lib/autoops/autoops.ts and lib/supabase/autoops.ts), gated behind the existing isTest signal, plus a PR conversation about defaults and rollout.
What this approach does NOT replace
- Tool Call Mocks for fine-grained per-test-case responses. Use both layers together when you want HTTP-level realism but per-case overrides.
- Real CRM testing. Mock responses are deterministic; the real AutoOps has rate limits, eventual consistency, validation rules, and partial failures we don't reproduce. Stage-prod calls against real AutoOps remain the integration backstop.
- The Supabase row. In the test-team setup, we bypass the DB entirely. In a real-enterprise generalization, the row would still exist (to hold real credentials) and the branching happens above the DB read, not instead of it.
Operational notes
- WireMock has no auth validation by default. Any
api_keyvalue works. This is fine for HTTP-level shape testing but doesn't catch credential bugs. - Stubs don't hot-reload. After adding files under
apps/api-mocker/mappings/, callcurl -sk -X POST https://api-mocker.dft.dawn/__admin/mappings/resetto reload. - Inspect what hit the mock via the journal:
curl -sk https://api-mocker.dft.dawn/__admin/requests | jq '.requests[].request.url'.
Source
- WireMock instance:
apps/api-mocker/(toolkit-hosted; container brought up viapnpm ce dc:up local) - Stub mappings:
apps/api-mocker/mappings/autoops/— one JSON per endpoint - AutoOps OpenAPI spec (reference shapes):
apps/docs/src/specs/openapi/autoops.json - URL substitution:
next/apps/web/lib/autoops/autoops.ts(AUTOOPS_BASE_URL) - Team config short-circuit:
next/apps/web/lib/supabase/autoops.ts(getAutoOpsConfig,getAutoOpsConfigByClientId) - Cross-shop routing:
next/apps/web/lib/autoops/shop-routing.ts+shop-routing.json - Test-traffic detection signals:
next/apps/web/lib/tools/test-call-mock.ts
Related
- Tool Call Mocks — the complementary per-tool mock layer
- How-To: Register a Hamming Agent
- EAS Replica Setup
- Hamming Features (investigation log)
- Ideas: Voice Agent Testing Primitive
- Ideas: Canonical CRM + Declarative Overlay