diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 42a079c..7067085 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -33,6 +33,7 @@ its **Status** is what changes. | Mock service | Contributor | Affiliation | Status | |---|---|---|---| +| `zendesk_support` | Yuxuan Zhang ([@reacher-z](https://github.com/reacher-z)) | Independent | staged | _Opens with the first community mock. Be the first._ diff --git a/real_replica_bench/mock_services/contrib/zendesk_support/README.md b/real_replica_bench/mock_services/contrib/zendesk_support/README.md new file mode 100644 index 0000000..f968cef --- /dev/null +++ b/real_replica_bench/mock_services/contrib/zendesk_support/README.md @@ -0,0 +1,96 @@ +# Zendesk Support API mock + +This directory contains an original, deterministic, offline replica of a useful +subset of the Zendesk Support ticket API. It is intended for benchmark tasks in +which an agent must triage customer-support work: search tickets, assign an +agent or group, apply tags, add public or internal comments, and move a ticket +through a valid status workflow. + +The implementation is not affiliated with or endorsed by Zendesk. It contains +no Zendesk code, captured responses, logos, fonts, screenshots, or other +third-party assets. All names, accounts, tickets, and messages in the committed +seed are synthetic and use the reserved `.test` top-level domain. + +## Runtime contract + +- Requested port: `3117` (override with `PORT`) +- Bind address: `127.0.0.1` (override with `HOST`) +- Health check: `GET /health` +- Launcher: `node /opt/mock_services/zendesk_support/server.js` +- Agent authentication: `Authorization: Bearer $SUPPORT_API_TOKEN` +- Verifier readout: `GET /__bench/state` with + `Authorization: Bearer $MOCK_VERIFIER_TOKEN` +- Seed override: `SUPPORT_MOCK_SEED=/absolute/path/to/seed.json` + +Standalone development defaults are `local-agent-token` and `bench-verifier`. +A benchmark task should always inject different task-local values and expose +only `SUPPORT_API_TOKEN` to the agent. + +The server has no package dependencies and makes no network requests. Every +mutation advances a seed-owned logical clock by exactly one minute. IDs come +from committed counters, so the same seed plus the same sequence of requests +produces byte-for-byte identical state and audit entries. + +## Agent API + +The mock implements JSON endpoints under `/api/v2/`: + +| Method and path | Behavior | +|---|---| +| `GET /tickets.json` | List, filter, sort, and paginate tickets | +| `GET /tickets/:id.json` | Read one ticket and its comments | +| `POST /tickets.json` | Create a ticket and initial comment | +| `PUT /tickets/:id.json` | Assign, classify, tag, comment, or change status | +| `GET /search.json?query=...` | Search subject, description, tags, and custom fields | +| `GET /users.json` | List synthetic users and roles | +| `GET /organizations.json` | List synthetic customer organizations | +| `GET /groups.json` | List support groups | +| `GET /ticket_fields.json` | Discover closed-set custom-field definitions | + +Ticket status, priority, type, assignee role, group, organization, tags, custom +fields, and status transitions are validated on the server. In particular, +`closed` tickets are immutable, and a ticket cannot jump directly from `open` +to `closed`. These rules apply to raw HTTP clients as well as future UIs. + +Example: + +```bash +curl -sS http://127.0.0.1:3117/api/v2/tickets.json?status=new \ + -H 'Authorization: Bearer local-agent-token' + +curl -sS -X PUT http://127.0.0.1:3117/api/v2/tickets/1005.json \ + -H 'Authorization: Bearer local-agent-token' \ + -H 'Content-Type: application/json' \ + --data '{"ticket":{"assignee_id":103,"status":"open","comment":{"body":"I am preparing the receipt.","public":true}}}' +``` + +## Verifier and seed shape + +`GET /__bench/state` returns the full authoritative state, including users, +organizations, groups, custom-field definitions, tickets, comments, the logical +clock and next-ID counters, and an append-only mutation audit. Verifiers should +score this readout rather than agent output. + +Verifier-only control routes are: + +- `GET /__bench/audit` +- `POST /__bench/reset` +- `POST /__bench/seed` with either a state object or `{ "state": ... }` + +The committed seed is [`seeds/default.json`](seeds/default.json). A task-specific +seed must preserve the same top-level arrays and metadata. Seed loading checks +duplicate IDs, enum values, required text, and all ticket/comment references +before the server accepts the state. + +## Local validation + +```bash +cd real_replica_bench/mock_services/contrib/zendesk_support +npm test +PORT=3117 SUPPORT_API_TOKEN=local-agent-token \ + MOCK_VERIFIER_TOKEN=bench-verifier npm start +``` + +The integration tests exercise authentication separation, filtering and search, +deterministic create/update behavior, invalid references, status transitions, +closed-ticket immutability, audit evidence, and exact reset behavior. diff --git a/real_replica_bench/mock_services/contrib/zendesk_support/package.json b/real_replica_bench/mock_services/contrib/zendesk_support/package.json new file mode 100644 index 0000000..568c716 --- /dev/null +++ b/real_replica_bench/mock_services/contrib/zendesk_support/package.json @@ -0,0 +1,14 @@ +{ + "name": "realreplicabench-zendesk-support-mock", + "version": "0.1.0", + "private": true, + "description": "Deterministic offline support-ticket API mock for RealReplicaBench", + "scripts": { + "start": "node server.js", + "test": "node --test test.js" + }, + "engines": { + "node": ">=20" + }, + "license": "Apache-2.0" +} diff --git a/real_replica_bench/mock_services/contrib/zendesk_support/seeds/default.json b/real_replica_bench/mock_services/contrib/zendesk_support/seeds/default.json new file mode 100644 index 0000000..de9af2b --- /dev/null +++ b/real_replica_bench/mock_services/contrib/zendesk_support/seeds/default.json @@ -0,0 +1,267 @@ +{ + "meta": { + "seed_version": "1", + "clock": "2026-08-01T09:00:00.000Z", + "next_ids": { + "ticket": 1006, + "comment": 5010 + } + }, + "users": [ + { + "id": 101, + "name": "Mina Patel", + "email": "mina.patel@example.test", + "role": "admin", + "active": true + }, + { + "id": 102, + "name": "Jon Bell", + "email": "jon.bell@example.test", + "role": "agent", + "active": true + }, + { + "id": 103, + "name": "Rosa Kim", + "email": "rosa.kim@example.test", + "role": "agent", + "active": true + }, + { + "id": 201, + "name": "Avery Chen", + "email": "avery.chen@example.test", + "role": "end-user", + "active": true, + "organization_id": 301 + }, + { + "id": 202, + "name": "Sam Okafor", + "email": "sam.okafor@example.test", + "role": "end-user", + "active": true, + "organization_id": 302 + } + ], + "organizations": [ + { + "id": 301, + "name": "Northwind Bikes", + "details": "Synthetic retail account", + "tags": ["retail", "priority-account"] + }, + { + "id": 302, + "name": "Cedar Labs", + "details": "Synthetic research account", + "tags": ["research"] + } + ], + "groups": [ + { + "id": 401, + "name": "Order Support" + }, + { + "id": 402, + "name": "Billing Escalations" + } + ], + "custom_field_definitions": [ + { + "id": 9001, + "name": "order_id", + "type": "order-id" + }, + { + "id": 9002, + "name": "refund_reason", + "type": "enum", + "options": ["damaged", "late-delivery", "missing-item", "other"] + }, + { + "id": 9003, + "name": "refund_amount", + "type": "amount" + } + ], + "tickets": [ + { + "id": 1001, + "subject": "Damaged rear light in order ORD-41027", + "description": "The rear light arrived with a cracked lens.", + "status": "open", + "priority": "high", + "type": "incident", + "requester_id": 201, + "submitter_id": 201, + "assignee_id": 102, + "group_id": 401, + "organization_id": 301, + "tags": ["damage", "order"], + "custom_fields": [ + {"id": 9001, "value": "ORD-41027"}, + {"id": 9002, "value": "damaged"}, + {"id": 9003, "value": 39.5} + ], + "via": {"channel": "web"}, + "created_at": "2026-08-01T08:00:00.000Z", + "updated_at": "2026-08-01T08:20:00.000Z" + }, + { + "id": 1002, + "subject": "Invoice contains duplicate charge", + "description": "July invoice appears to bill the workspace twice.", + "status": "pending", + "priority": "urgent", + "type": "problem", + "requester_id": 202, + "submitter_id": 202, + "assignee_id": 103, + "group_id": 402, + "organization_id": 302, + "tags": ["billing", "duplicate-charge"], + "custom_fields": [], + "via": {"channel": "email"}, + "created_at": "2026-07-31T15:00:00.000Z", + "updated_at": "2026-08-01T07:45:00.000Z" + }, + { + "id": 1003, + "subject": "Tracking page has not updated", + "description": "Shipment status has remained unchanged for three days.", + "status": "hold", + "priority": "normal", + "type": "question", + "requester_id": 201, + "submitter_id": 201, + "assignee_id": 102, + "group_id": 401, + "organization_id": 301, + "tags": ["delivery", "tracking"], + "custom_fields": [ + {"id": 9001, "value": "ORD-40988"}, + {"id": 9002, "value": "late-delivery"} + ], + "via": {"channel": "web"}, + "created_at": "2026-07-29T10:00:00.000Z", + "updated_at": "2026-07-31T09:10:00.000Z" + }, + { + "id": 1004, + "subject": "Update shipping address before dispatch", + "description": "Please use the address already saved on the account.", + "status": "solved", + "priority": "normal", + "type": "task", + "requester_id": 201, + "submitter_id": 201, + "assignee_id": 103, + "group_id": 401, + "organization_id": 301, + "tags": ["address-change", "order"], + "custom_fields": [ + {"id": 9001, "value": "ORD-41002"} + ], + "via": {"channel": "email"}, + "created_at": "2026-07-30T11:00:00.000Z", + "updated_at": "2026-07-30T12:30:00.000Z" + }, + { + "id": 1005, + "subject": "Request a copy of the receipt", + "description": "A PDF receipt is needed for expense reporting.", + "status": "new", + "priority": "low", + "type": "question", + "requester_id": 202, + "submitter_id": 202, + "assignee_id": null, + "group_id": 402, + "organization_id": 302, + "tags": ["billing", "receipt"], + "custom_fields": [], + "via": {"channel": "web"}, + "created_at": "2026-08-01T08:45:00.000Z", + "updated_at": "2026-08-01T08:45:00.000Z" + } + ], + "comments": [ + { + "id": 5001, + "ticket_id": 1001, + "author_id": 201, + "body": "The rear light arrived with a cracked lens.", + "public": true, + "created_at": "2026-08-01T08:00:00.000Z" + }, + { + "id": 5002, + "ticket_id": 1001, + "author_id": 102, + "body": "Thanks for the photo description. I am checking replacement stock.", + "public": true, + "created_at": "2026-08-01T08:20:00.000Z" + }, + { + "id": 5003, + "ticket_id": 1002, + "author_id": 202, + "body": "July invoice appears to bill the workspace twice.", + "public": true, + "created_at": "2026-07-31T15:00:00.000Z" + }, + { + "id": 5004, + "ticket_id": 1002, + "author_id": 103, + "body": "Finance is comparing the two transaction references.", + "public": false, + "created_at": "2026-08-01T07:45:00.000Z" + }, + { + "id": 5005, + "ticket_id": 1003, + "author_id": 201, + "body": "Shipment status has remained unchanged for three days.", + "public": true, + "created_at": "2026-07-29T10:00:00.000Z" + }, + { + "id": 5006, + "ticket_id": 1003, + "author_id": 102, + "body": "The carrier trace is open; holding until it responds.", + "public": true, + "created_at": "2026-07-31T09:10:00.000Z" + }, + { + "id": 5007, + "ticket_id": 1004, + "author_id": 201, + "body": "Please use the address already saved on the account.", + "public": true, + "created_at": "2026-07-30T11:00:00.000Z" + }, + { + "id": 5008, + "ticket_id": 1004, + "author_id": 103, + "body": "The dispatch address was updated before label creation.", + "public": true, + "created_at": "2026-07-30T12:30:00.000Z" + }, + { + "id": 5009, + "ticket_id": 1005, + "author_id": 202, + "body": "A PDF receipt is needed for expense reporting.", + "public": true, + "created_at": "2026-08-01T08:45:00.000Z" + } + ], + "audit": [] +} diff --git a/real_replica_bench/mock_services/contrib/zendesk_support/server.js b/real_replica_bench/mock_services/contrib/zendesk_support/server.js new file mode 100644 index 0000000..4f6381b --- /dev/null +++ b/real_replica_bench/mock_services/contrib/zendesk_support/server.js @@ -0,0 +1,587 @@ +"use strict"; + +const fs = require("node:fs"); +const http = require("node:http"); +const path = require("node:path"); +const { URL } = require("node:url"); + +const DEFAULT_SEED = path.join(__dirname, "seeds", "default.json"); +const STATUSES = new Set(["new", "open", "pending", "hold", "solved", "closed"]); +const PRIORITIES = new Set(["low", "normal", "high", "urgent"]); +const TYPES = new Set(["question", "incident", "problem", "task"]); +const ROLES = new Set(["admin", "agent", "end-user"]); +const TRANSITIONS = { + new: new Set(["new", "open", "pending", "hold", "solved"]), + open: new Set(["open", "pending", "hold", "solved"]), + pending: new Set(["pending", "open", "hold", "solved"]), + hold: new Set(["hold", "open", "pending", "solved"]), + solved: new Set(["solved", "open", "closed"]), + closed: new Set(["closed"]), +}; + +class ApiError extends Error { + constructor(status, code, message, details) { + super(message); + this.status = status; + this.code = code; + this.details = details; + } +} + +function clone(value) { + return JSON.parse(JSON.stringify(value)); +} + +function integer(value, name, { nullable = false } = {}) { + if (nullable && value === null) return null; + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new ApiError(422, "invalid_field", `${name} must be an integer`); + } + return parsed; +} + +function nonEmptyString(value, name, maxLength = 5000) { + if (typeof value !== "string" || !value.trim()) { + throw new ApiError(422, "invalid_field", `${name} must be a non-empty string`); + } + const result = value.trim(); + if (result.length > maxLength) { + throw new ApiError(422, "invalid_field", `${name} exceeds ${maxLength} characters`); + } + return result; +} + +function enumValue(value, name, values) { + if (!values.has(value)) { + throw new ApiError(422, "invalid_field", `${name} must be one of: ${[...values].join(", ")}`); + } + return value; +} + +function normalizedTags(value) { + if (!Array.isArray(value)) { + throw new ApiError(422, "invalid_field", "tags must be an array"); + } + if (value.length > 50) { + throw new ApiError(422, "invalid_field", "tags cannot contain more than 50 values"); + } + const tags = value.map((tag) => nonEmptyString(tag, "tag", 64).toLowerCase()); + for (const tag of tags) { + if (!/^[a-z0-9][a-z0-9_-]*$/.test(tag)) { + throw new ApiError(422, "invalid_field", `invalid tag: ${tag}`); + } + } + return [...new Set(tags)].sort(); +} + +function parseBearer(request) { + const match = String(request.headers.authorization || "").match(/^Bearer\s+(.+)$/i); + return match ? match[1].trim() : ""; +} + +function sendJson(response, status, payload, extraHeaders = {}) { + const body = JSON.stringify(payload); + response.writeHead(status, { + "content-type": "application/json; charset=utf-8", + "content-length": Buffer.byteLength(body), + "cache-control": "no-store", + ...extraHeaders, + }); + response.end(body); +} + +async function readJson(request) { + let size = 0; + const chunks = []; + for await (const chunk of request) { + size += chunk.length; + if (size > 1024 * 1024) { + throw new ApiError(413, "payload_too_large", "request body exceeds 1 MiB"); + } + chunks.push(chunk); + } + if (!chunks.length) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString("utf8")); + } catch { + throw new ApiError(400, "invalid_json", "request body is not valid JSON"); + } +} + +function assertUniqueIds(items, label) { + const ids = new Set(); + for (const item of items) { + if (!item || !Number.isSafeInteger(item.id)) { + throw new ApiError(422, "invalid_seed", `${label} entries require integer ids`); + } + if (ids.has(item.id)) { + throw new ApiError(422, "invalid_seed", `${label} contains duplicate id ${item.id}`); + } + ids.add(item.id); + } + return ids; +} + +function validateSeed(candidate) { + const state = clone(candidate); + for (const key of [ + "users", + "organizations", + "groups", + "custom_field_definitions", + "tickets", + "comments", + "audit", + ]) { + if (!Array.isArray(state[key])) { + throw new ApiError(422, "invalid_seed", `${key} must be an array`); + } + } + if (!state.meta || !Number.isSafeInteger(state.meta.next_ids?.ticket) || + !Number.isSafeInteger(state.meta.next_ids?.comment) || Number.isNaN(Date.parse(state.meta.clock))) { + throw new ApiError(422, "invalid_seed", "meta requires clock and integer next_ids"); + } + + const userIds = assertUniqueIds(state.users, "users"); + const organizationIds = assertUniqueIds(state.organizations, "organizations"); + const groupIds = assertUniqueIds(state.groups, "groups"); + const fieldIds = assertUniqueIds(state.custom_field_definitions, "custom_field_definitions"); + const ticketIds = assertUniqueIds(state.tickets, "tickets"); + const commentIds = assertUniqueIds(state.comments, "comments"); + if (state.meta.next_ids.ticket <= Math.max(0, ...ticketIds) || + state.meta.next_ids.comment <= Math.max(0, ...commentIds)) { + throw new ApiError(422, "invalid_seed", "next_ids must be greater than every committed id"); + } + + for (const user of state.users) { + enumValue(user.role, "user.role", ROLES); + nonEmptyString(user.name, "user.name", 200); + nonEmptyString(user.email, "user.email", 320); + if (typeof user.active !== "boolean") { + throw new ApiError(422, "invalid_seed", `user ${user.id} active must be boolean`); + } + if (user.organization_id != null && !organizationIds.has(user.organization_id)) { + throw new ApiError(422, "invalid_seed", `user ${user.id} references an unknown organization`); + } + } + + for (const ticket of state.tickets) { + enumValue(ticket.status, "ticket.status", STATUSES); + enumValue(ticket.priority, "ticket.priority", PRIORITIES); + enumValue(ticket.type, "ticket.type", TYPES); + nonEmptyString(ticket.subject, "ticket.subject", 300); + nonEmptyString(ticket.description, "ticket.description", 10000); + if (!userIds.has(ticket.requester_id) || !userIds.has(ticket.submitter_id)) { + throw new ApiError(422, "invalid_seed", `ticket ${ticket.id} references an unknown requester or submitter`); + } + if (ticket.assignee_id != null) { + const assignee = state.users.find((user) => user.id === ticket.assignee_id); + if (!assignee || !assignee.active || !["admin", "agent"].includes(assignee.role)) { + throw new ApiError(422, "invalid_seed", `ticket ${ticket.id} references an invalid assignee`); + } + } + if (!groupIds.has(ticket.group_id) || !organizationIds.has(ticket.organization_id)) { + throw new ApiError(422, "invalid_seed", `ticket ${ticket.id} references an unknown group or organization`); + } + normalizedTags(ticket.tags); + for (const field of ticket.custom_fields || []) { + if (!fieldIds.has(field.id)) { + throw new ApiError(422, "invalid_seed", `ticket ${ticket.id} references unknown field ${field.id}`); + } + } + } + for (const comment of state.comments) { + if (!ticketIds.has(comment.ticket_id) || !userIds.has(comment.author_id)) { + throw new ApiError(422, "invalid_seed", `comment ${comment.id} has an unknown ticket or author`); + } + nonEmptyString(comment.body, "comment.body", 10000); + if (typeof comment.public !== "boolean") { + throw new ApiError(422, "invalid_seed", `comment ${comment.id} public must be boolean`); + } + } + return state; +} + +class SupportStore { + constructor(seed) { + this.originalSeed = validateSeed(seed); + this.state = clone(this.originalSeed); + } + + reset() { + this.state = clone(this.originalSeed); + return this.snapshot(); + } + + reseed(seed) { + this.originalSeed = validateSeed(seed); + return this.reset(); + } + + snapshot() { + return clone(this.state); + } + + transaction(operation) { + const before = this.snapshot(); + try { + return operation(); + } catch (error) { + this.state = before; + throw error; + } + } + + tick() { + const next = new Date(Date.parse(this.state.meta.clock) + 60_000).toISOString(); + this.state.meta.clock = next; + return next; + } + + record(action, entityType, entityId, details = {}) { + const entry = { + seq: this.state.audit.length + 1, + at: this.state.meta.clock, + action, + entity_type: entityType, + entity_id: entityId, + details: clone(details), + }; + this.state.audit.push(entry); + return entry; + } + + user(id, { role } = {}) { + const user = this.state.users.find((item) => item.id === id && item.active); + if (!user || (role && !role.has(user.role))) { + throw new ApiError(422, "invalid_reference", `unknown or inactive user ${id}`); + } + return user; + } + + ticket(id) { + const ticket = this.state.tickets.find((item) => item.id === id); + if (!ticket) throw new ApiError(404, "record_not_found", `ticket ${id} was not found`); + return ticket; + } + + validateOrganization(id) { + if (!this.state.organizations.some((item) => item.id === id)) { + throw new ApiError(422, "invalid_reference", `unknown organization ${id}`); + } + return id; + } + + validateGroup(id) { + if (!this.state.groups.some((item) => item.id === id)) { + throw new ApiError(422, "invalid_reference", `unknown group ${id}`); + } + return id; + } + + customFields(fields) { + if (!Array.isArray(fields)) { + throw new ApiError(422, "invalid_field", "custom_fields must be an array"); + } + const definitions = new Map(this.state.custom_field_definitions.map((item) => [item.id, item])); + const seen = new Set(); + const result = []; + for (const raw of fields) { + const id = integer(raw?.id, "custom_fields.id"); + const definition = definitions.get(id); + if (!definition || seen.has(id)) { + throw new ApiError(422, "invalid_field", `unknown or duplicate custom field ${id}`); + } + seen.add(id); + let value = raw.value; + if (definition.type === "order-id") { + value = nonEmptyString(value, definition.name, 20).toUpperCase(); + if (!/^ORD-[0-9]{5}$/.test(value)) { + throw new ApiError(422, "invalid_field", `${definition.name} must match ORD-12345`); + } + } else if (definition.type === "enum") { + if (!definition.options.includes(value)) { + throw new ApiError(422, "invalid_field", `${definition.name} must be one of: ${definition.options.join(", ")}`); + } + } else if (definition.type === "amount") { + value = Number(value); + if (!Number.isFinite(value) || value < 0 || value > 100000 || + Math.abs(Math.round(value * 100) - value * 100) > 1e-9) { + throw new ApiError(422, "invalid_field", `${definition.name} must be a non-negative amount with at most two decimals`); + } + } + result.push({ id, value }); + } + return result.sort((left, right) => left.id - right.id); + } + + comment(ticket, raw, defaultAuthorId) { + if (!raw) return null; + const body = nonEmptyString(raw.body, "comment.body", 10000); + const authorId = integer(raw.author_id ?? defaultAuthorId, "comment.author_id"); + this.user(authorId); + if (raw.public !== undefined && typeof raw.public !== "boolean") { + throw new ApiError(422, "invalid_field", "comment.public must be boolean"); + } + const comment = { + id: this.state.meta.next_ids.comment++, + ticket_id: ticket.id, + author_id: authorId, + body, + public: raw.public !== false, + created_at: this.state.meta.clock, + }; + this.state.comments.push(comment); + return comment; + } + + createTicket(raw) { + const allowed = new Set([ + "subject", "description", "status", "priority", "type", "requester_id", + "submitter_id", "assignee_id", "group_id", "organization_id", "tags", + "custom_fields", "via", "comment", + ]); + const unknown = Object.keys(raw).filter((key) => !allowed.has(key)); + if (unknown.length) { + throw new ApiError(422, "invalid_field", `unknown ticket fields: ${unknown.join(", ")}`); + } + const requesterId = integer(raw.requester_id, "requester_id"); + const submitterId = integer(raw.submitter_id ?? requesterId, "submitter_id"); + const requester = this.user(requesterId); + this.user(submitterId); + const assigneeId = raw.assignee_id == null ? null : integer(raw.assignee_id, "assignee_id"); + if (assigneeId != null) this.user(assigneeId, { role: new Set(["admin", "agent"]) }); + const organizationId = integer(raw.organization_id ?? requester.organization_id, "organization_id"); + this.validateOrganization(organizationId); + const groupId = integer(raw.group_id, "group_id"); + this.validateGroup(groupId); + const description = raw.description ?? raw.comment?.body; + const channel = raw.via?.channel ?? "api"; + if (!["api", "email", "web"].includes(channel)) { + throw new ApiError(422, "invalid_field", "via.channel must be api, email, or web"); + } + const at = this.tick(); + const ticket = { + id: this.state.meta.next_ids.ticket++, + subject: nonEmptyString(raw.subject, "subject", 300), + description: nonEmptyString(description, "description", 10000), + status: enumValue(raw.status ?? "new", "status", STATUSES), + priority: enumValue(raw.priority ?? "normal", "priority", PRIORITIES), + type: enumValue(raw.type ?? "question", "type", TYPES), + requester_id: requesterId, + submitter_id: submitterId, + assignee_id: assigneeId, + group_id: groupId, + organization_id: organizationId, + tags: normalizedTags(raw.tags ?? []), + custom_fields: this.customFields(raw.custom_fields ?? []), + via: { channel }, + created_at: at, + updated_at: at, + }; + this.state.tickets.push(ticket); + this.comment(ticket, raw.comment || { body: ticket.description, public: true }, submitterId); + this.record("ticket.created", "ticket", ticket.id, { status: ticket.status }); + return clone(ticket); + } + + updateTicket(id, raw) { + const ticket = this.ticket(id); + if (ticket.status === "closed") { + throw new ApiError(409, "ticket_closed", "closed tickets are immutable"); + } + if (!Object.keys(raw).length) { + throw new ApiError(422, "invalid_request", "ticket update cannot be empty"); + } + const before = clone(ticket); + if (raw.status !== undefined) { + enumValue(raw.status, "status", STATUSES); + if (!TRANSITIONS[ticket.status].has(raw.status)) { + throw new ApiError(409, "invalid_transition", `cannot transition ${ticket.status} to ${raw.status}`); + } + ticket.status = raw.status; + } + if (raw.subject !== undefined) ticket.subject = nonEmptyString(raw.subject, "subject", 300); + if (raw.priority !== undefined) ticket.priority = enumValue(raw.priority, "priority", PRIORITIES); + if (raw.type !== undefined) ticket.type = enumValue(raw.type, "type", TYPES); + if (raw.requester_id !== undefined) { + ticket.requester_id = integer(raw.requester_id, "requester_id"); + this.user(ticket.requester_id); + } + if (raw.assignee_id !== undefined) { + ticket.assignee_id = integer(raw.assignee_id, "assignee_id", { nullable: true }); + if (ticket.assignee_id != null) this.user(ticket.assignee_id, { role: new Set(["admin", "agent"]) }); + } + if (raw.organization_id !== undefined) { + ticket.organization_id = integer(raw.organization_id, "organization_id"); + this.validateOrganization(ticket.organization_id); + } + if (raw.group_id !== undefined) { + ticket.group_id = integer(raw.group_id, "group_id"); + this.validateGroup(ticket.group_id); + } + if (raw.tags !== undefined) ticket.tags = normalizedTags(raw.tags); + if (raw.custom_fields !== undefined) ticket.custom_fields = this.customFields(raw.custom_fields); + + const allowed = new Set([ + "status", "subject", "priority", "type", "requester_id", "assignee_id", + "organization_id", "group_id", "tags", "custom_fields", "comment", + ]); + const unknown = Object.keys(raw).filter((key) => !allowed.has(key)); + if (unknown.length) { + throw new ApiError(422, "invalid_field", `unknown ticket fields: ${unknown.join(", ")}`); + } + + ticket.updated_at = this.tick(); + const comment = this.comment(ticket, raw.comment, ticket.assignee_id ?? ticket.submitter_id); + const changed = Object.keys(ticket).filter((key) => JSON.stringify(ticket[key]) !== JSON.stringify(before[key])); + this.record("ticket.updated", "ticket", ticket.id, { + changed_fields: changed.sort(), + comment_id: comment?.id ?? null, + }); + return clone(ticket); + } +} + +function loadSeed(seedPath) { + return JSON.parse(fs.readFileSync(seedPath, "utf8")); +} + +function createSupportServer(options = {}) { + const agentToken = options.agentToken ?? process.env.SUPPORT_API_TOKEN ?? "local-agent-token"; + const verifierToken = options.verifierToken ?? process.env.MOCK_VERIFIER_TOKEN ?? "bench-verifier"; + const seedPath = options.seedPath ?? process.env.SUPPORT_MOCK_SEED ?? DEFAULT_SEED; + const store = new SupportStore(options.seed ?? loadSeed(seedPath)); + + return http.createServer(async (request, response) => { + try { + const url = new URL(request.url, "http://127.0.0.1"); + if (request.method === "GET" && url.pathname === "/health") { + return sendJson(response, 200, { ok: true, service: "zendesk_support", seed_version: store.state.meta.seed_version }); + } + + const isBench = url.pathname.startsWith("/__bench/"); + const token = parseBearer(request); + if (isBench) { + if (!verifierToken || token !== verifierToken) { + throw new ApiError(token ? 403 : 401, "access_denied", "invalid or missing verifier token"); + } + } else if (!agentToken || token !== agentToken) { + throw new ApiError(token ? 403 : 401, "access_denied", "invalid or missing API token"); + } + + if (request.method === "GET" && url.pathname === "/__bench/state") { + return sendJson(response, 200, { state: store.snapshot() }); + } + if (request.method === "GET" && url.pathname === "/__bench/audit") { + return sendJson(response, 200, { audit: clone(store.state.audit) }); + } + if (request.method === "POST" && url.pathname === "/__bench/reset") { + return sendJson(response, 200, { state: store.reset() }); + } + if (request.method === "POST" && url.pathname === "/__bench/seed") { + const body = await readJson(request); + return sendJson(response, 200, { state: store.reseed(body.state ?? body) }); + } + + if (request.method === "GET" && url.pathname === "/api/v2/users.json") { + return sendJson(response, 200, { users: clone(store.state.users) }); + } + if (request.method === "GET" && url.pathname === "/api/v2/organizations.json") { + return sendJson(response, 200, { organizations: clone(store.state.organizations) }); + } + if (request.method === "GET" && url.pathname === "/api/v2/groups.json") { + return sendJson(response, 200, { groups: clone(store.state.groups) }); + } + if (request.method === "GET" && url.pathname === "/api/v2/ticket_fields.json") { + return sendJson(response, 200, { ticket_fields: clone(store.state.custom_field_definitions) }); + } + + if (request.method === "GET" && url.pathname === "/api/v2/tickets.json") { + let tickets = clone(store.state.tickets); + for (const field of ["status", "priority", "type"]) { + if (url.searchParams.has(field)) tickets = tickets.filter((item) => item[field] === url.searchParams.get(field)); + } + for (const field of ["assignee_id", "requester_id", "organization_id", "group_id"]) { + if (url.searchParams.has(field)) { + const id = integer(url.searchParams.get(field), field); + tickets = tickets.filter((item) => item[field] === id); + } + } + if (url.searchParams.has("tag")) tickets = tickets.filter((item) => item.tags.includes(url.searchParams.get("tag").toLowerCase())); + const sortBy = url.searchParams.get("sort_by") ?? "id"; + if (!["id", "created_at", "updated_at", "priority", "status"].includes(sortBy)) { + throw new ApiError(422, "invalid_parameter", "unsupported sort_by value"); + } + const direction = url.searchParams.get("sort_order") === "desc" ? -1 : 1; + tickets.sort((left, right) => (left[sortBy] < right[sortBy] ? -direction : left[sortBy] > right[sortBy] ? direction : left.id - right.id)); + const page = Math.max(1, integer(url.searchParams.get("page") ?? 1, "page")); + const perPage = Math.min(100, Math.max(1, integer(url.searchParams.get("per_page") ?? 100, "per_page"))); + const start = (page - 1) * perPage; + return sendJson(response, 200, { + tickets: tickets.slice(start, start + perPage), + count: tickets.length, + page, + per_page: perPage, + next_page: start + perPage < tickets.length ? page + 1 : null, + }); + } + + const ticketMatch = url.pathname.match(/^\/api\/v2\/tickets\/(\d+)\.json$/); + if (ticketMatch && request.method === "GET") { + const ticket = store.ticket(Number(ticketMatch[1])); + const comments = store.state.comments.filter((item) => item.ticket_id === ticket.id); + return sendJson(response, 200, { ticket: clone(ticket), comments: clone(comments) }); + } + if (ticketMatch && request.method === "PUT") { + const body = await readJson(request); + if (!body.ticket || typeof body.ticket !== "object" || Array.isArray(body.ticket)) { + throw new ApiError(422, "invalid_request", "body.ticket must be an object"); + } + const ticket = store.transaction(() => store.updateTicket(Number(ticketMatch[1]), body.ticket)); + return sendJson(response, 200, { ticket }); + } + if (request.method === "POST" && url.pathname === "/api/v2/tickets.json") { + const body = await readJson(request); + if (!body.ticket || typeof body.ticket !== "object" || Array.isArray(body.ticket)) { + throw new ApiError(422, "invalid_request", "body.ticket must be an object"); + } + const ticket = store.transaction(() => store.createTicket(body.ticket)); + return sendJson(response, 201, { ticket }); + } + + if (request.method === "GET" && url.pathname === "/api/v2/search.json") { + const query = nonEmptyString(url.searchParams.get("query"), "query", 200).toLowerCase(); + const results = store.state.tickets.filter((ticket) => { + const haystack = [ticket.subject, ticket.description, ...ticket.tags, ...ticket.custom_fields.map((field) => field.value)].join(" ").toLowerCase(); + return haystack.includes(query); + }).map((ticket) => ({ ...clone(ticket), result_type: "ticket" })); + return sendJson(response, 200, { results, count: results.length }); + } + + throw new ApiError(404, "route_not_found", `${request.method} ${url.pathname} is not implemented`); + } catch (error) { + const status = error instanceof ApiError ? error.status : 500; + const payload = { + error: error instanceof ApiError ? error.code : "internal_error", + description: error instanceof ApiError ? error.message : "internal server error", + }; + if (error instanceof ApiError && error.details !== undefined) payload.details = error.details; + if (!(error instanceof ApiError)) console.error(error); + if (!response.headersSent) sendJson(response, status, payload); + else response.end(); + } + }); +} + +if (require.main === module) { + const port = Number(process.env.PORT || 3117); + const host = process.env.HOST || "127.0.0.1"; + const server = createSupportServer(); + server.listen(port, host, () => { + console.log(`zendesk_support mock listening on http://${host}:${port}`); + }); +} + +module.exports = { ApiError, SupportStore, createSupportServer, validateSeed }; diff --git a/real_replica_bench/mock_services/contrib/zendesk_support/test.js b/real_replica_bench/mock_services/contrib/zendesk_support/test.js new file mode 100644 index 0000000..1be72be --- /dev/null +++ b/real_replica_bench/mock_services/contrib/zendesk_support/test.js @@ -0,0 +1,181 @@ +"use strict"; + +const assert = require("node:assert/strict"); +const { after, before, beforeEach, test } = require("node:test"); +const { createSupportServer } = require("./server"); + +const AGENT_TOKEN = "test-agent-token"; +const VERIFIER_TOKEN = "test-verifier-token"; +let baseUrl; +let server; + +async function request(path, { method = "GET", body, token = AGENT_TOKEN } = {}) { + const response = await fetch(`${baseUrl}${path}`, { + method, + headers: { + authorization: token ? `Bearer ${token}` : "", + ...(body === undefined ? {} : { "content-type": "application/json" }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + return { response, json: await response.json() }; +} + +before(async () => { + server = createSupportServer({ agentToken: AGENT_TOKEN, verifierToken: VERIFIER_TOKEN }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const address = server.address(); + baseUrl = `http://127.0.0.1:${address.port}`; +}); + +after(async () => { + await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve())); +}); + +beforeEach(async () => { + const { response } = await request("/__bench/reset", { method: "POST", token: VERIFIER_TOKEN }); + assert.equal(response.status, 200); +}); + +test("health is public and agent/verifier surfaces use distinct tokens", async () => { + const health = await request("/health", { token: "" }); + assert.equal(health.response.status, 200); + assert.equal(health.json.service, "zendesk_support"); + + assert.equal((await request("/api/v2/tickets.json", { token: "" })).response.status, 401); + assert.equal((await request("/api/v2/tickets.json", { token: VERIFIER_TOKEN })).response.status, 403); + assert.equal((await request("/__bench/state", { token: AGENT_TOKEN })).response.status, 403); +}); + +test("ticket list filters, paginates, and searches deterministically", async () => { + const filtered = await request("/api/v2/tickets.json?status=open&tag=damage&per_page=1"); + assert.equal(filtered.response.status, 200); + assert.equal(filtered.json.count, 1); + assert.equal(filtered.json.tickets[0].id, 1001); + assert.equal(filtered.json.next_page, null); + + const searched = await request("/api/v2/search.json?query=ORD-40988"); + assert.equal(searched.response.status, 200); + assert.deepEqual(searched.json.results.map((item) => item.id), [1003]); +}); + +test("creating a ticket assigns deterministic ids, timestamps, and audit evidence", async () => { + const created = await request("/api/v2/tickets.json", { + method: "POST", + body: { + ticket: { + subject: "Missing bottle cage from order", + description: "The parcel did not include the bottle cage.", + requester_id: 201, + assignee_id: 102, + group_id: 401, + priority: "high", + tags: ["Order", "missing_item", "order"], + custom_fields: [ + { id: 9001, value: "ord-41031" }, + { id: 9002, value: "missing-item" } + ] + } + } + }); + assert.equal(created.response.status, 201); + assert.equal(created.json.ticket.id, 1006); + assert.equal(created.json.ticket.created_at, "2026-08-01T09:01:00.000Z"); + assert.deepEqual(created.json.ticket.tags, ["missing_item", "order"]); + + const state = await request("/__bench/state", { token: VERIFIER_TOKEN }); + assert.equal(state.json.state.comments.at(-1).id, 5010); + assert.deepEqual(state.json.state.audit.at(-1), { + seq: 1, + at: "2026-08-01T09:01:00.000Z", + action: "ticket.created", + entity_type: "ticket", + entity_id: 1006, + details: { status: "new" } + }); +}); + +test("invalid creates are atomic and unknown fields are rejected", async () => { + const before = await request("/__bench/state", { token: VERIFIER_TOKEN }); + const invalid = await request("/api/v2/tickets.json", { + method: "POST", + body: { + ticket: { + subject: "Unsupported payload", + description: "This request includes an undeclared field.", + requester_id: 201, + group_id: 401, + severity: "critical" + } + } + }); + assert.equal(invalid.response.status, 422); + assert.equal(invalid.json.error, "invalid_field"); + const after = await request("/__bench/state", { token: VERIFIER_TOKEN }); + assert.deepEqual(after.json.state, before.json.state); +}); + +test("updates enforce references and record comments through the verifier readout", async () => { + const beforeInvalid = await request("/__bench/state", { token: VERIFIER_TOKEN }); + const invalid = await request("/api/v2/tickets/1005.json", { + method: "PUT", + body: { ticket: { assignee_id: 201 } } + }); + assert.equal(invalid.response.status, 422); + assert.equal(invalid.json.error, "invalid_reference"); + const afterInvalid = await request("/__bench/state", { token: VERIFIER_TOKEN }); + assert.deepEqual(afterInvalid.json.state, beforeInvalid.json.state); + + const updated = await request("/api/v2/tickets/1005.json", { + method: "PUT", + body: { + ticket: { + status: "open", + assignee_id: 103, + tags: ["billing", "receipt", "expense"], + comment: { body: "Receipt export is in progress.", public: false, author_id: 103 } + } + } + }); + assert.equal(updated.response.status, 200); + assert.equal(updated.json.ticket.status, "open"); + assert.equal(updated.json.ticket.updated_at, "2026-08-01T09:01:00.000Z"); + + const state = await request("/__bench/state", { token: VERIFIER_TOKEN }); + assert.equal(state.json.state.comments.at(-1).body, "Receipt export is in progress."); + assert.equal(state.json.state.comments.at(-1).public, false); + assert.equal(state.json.state.audit.at(-1).details.comment_id, 5010); + assert.deepEqual(state.json.state.audit.at(-1).details.changed_fields, + ["assignee_id", "status", "tags", "updated_at"]); +}); + +test("status transitions and closed-ticket immutability are server-side rules", async () => { + const badTransition = await request("/api/v2/tickets/1001.json", { + method: "PUT", + body: { ticket: { status: "closed" } } + }); + assert.equal(badTransition.response.status, 409); + assert.equal(badTransition.json.error, "invalid_transition"); + + assert.equal((await request("/api/v2/tickets/1004.json", { + method: "PUT", + body: { ticket: { status: "closed" } } + })).response.status, 200); + + const immutable = await request("/api/v2/tickets/1004.json", { + method: "PUT", + body: { ticket: { subject: "Try to mutate a closed ticket" } } + }); + assert.equal(immutable.response.status, 409); + assert.equal(immutable.json.error, "ticket_closed"); +}); + +test("verifier reset restores an exact seed snapshot", async () => { + const original = await request("/__bench/state", { token: VERIFIER_TOKEN }); + await request("/api/v2/tickets/1001.json", { + method: "PUT", + body: { ticket: { priority: "urgent" } } + }); + const reset = await request("/__bench/reset", { method: "POST", token: VERIFIER_TOKEN }); + assert.deepEqual(reset.json.state, original.json.state); +});