morphit/apps/matrix-bot/scripts/api-response-shape-smoke.ts

1167 lines
32 KiB
TypeScript

#!/usr/bin/env tsx
/**
* api-response-shape-smoke — every public-API response shape
* gets a zod schema, AND a TS-type-cross-check confirms the
* schema matches the canonical TypeScript interface from
* @morphit/indexer-client.
*
* Pattern parallel to sidecar-envelope-smoke (cp14):
* - sidecar-envelope-smoke validates bash sidecar emit() output
* against the LogRecord shape;
* - this smoke validates HTTP-API responses against the shapes
* declared in packages/indexer-client/src/index.ts.
*
* Each scenario:
* 1. Declares a zod schema for one response type.
* 2. Declares a sample literal `typed as` the canonical TS
* interface from @morphit/indexer-client. If the schema
* and interface diverge (a new required field added to
* the TS but not to the schema), tsc will reject the
* sample literal because zod.parse() returns `unknown`.
* 3. Validates the sample literal against the schema.
* 4. Validates a deliberately-malformed copy of the sample
* against the schema (negative test — must reject).
*
* This is a CONTRACT smoke, not a behavior smoke. It doesn't
* spawn the indexer or hit real endpoints. Real-endpoint
* behavior is covered by apps/indexer/test/. The point here is
* to lock the schema-as-contract.
*
* Why this matters: cp9 lessons applied to HTTP API. If the
* indexer's response shape drifts from what the frontend
* consumes (a renamed field, a removed field, a new required
* field), this smoke catches it at CI time rather than at
* production-debug time.
*/
import { z } from 'zod';
import type {
HealthResponse,
ListingFeeResponse,
ReleaseResponse,
OperatorRecord,
ErrorResponse,
InstanceResponse,
InstanceDirectoryEntry,
OrderRecord,
FeedbackSummary,
ChatAdmissionResponse,
// cp16 additions:
OrderViewsResponse,
OrderViewIncrementResponse,
OrderbookResponse,
FeaturedBid,
FeaturedSlot,
FeaturedOrderbookResponse,
AccountOrdersResponse,
ProfileResponse,
OperatorStats,
OperatorsResponse,
ChatIdentityResponse,
ConversationSummary,
ConversationsResponse,
BlockEntry,
BlocksResponse,
ChatHistoryResponse,
ChatMessageRecord,
InstanceDirectoryResponse,
// cp17 additions (final ~12 lower-traffic):
ClearingPricePoint,
ClearingPriceHistoryResponse,
BatchProfilesResponse,
FeedbackRecord,
FeedbackResponseRecord,
AccountFeedbackResponse,
AccountFeedbackGivenResponse,
ChatReadStateEntry,
ChatReadStateResponse,
AttestorEligibilityResponse,
StrangerFeeQuoteResponse
} from '@morphit/indexer-client';
// ─── Schemas ───────────────────────────────────────────────────
//
// Mirror the TS interfaces in packages/indexer-client/src/index.ts.
// Optional fields use .optional(); nullable fields use .nullable().
// Both — .nullable().optional().
const HealthSchema = z.object({
status: z.enum(['ok', 'degraded']),
version: z.string(),
uptime_sec: z.number(),
chain_head_block: z.number(),
indexed_block: z.number(),
lag_blocks: z.number(),
lag_blocks_note: z.string().optional(),
stale: z.boolean()
});
const ListingFeeSchema = z.object({
base_fee_blurt: z.number(),
feature_fee_blurt_per_hour: z.number(),
quote_ttl_seconds: z.number(),
// cp128 rename: pre-cp128 these were `base_fee_usd` /
// `blurt_price_usd` (USD hardcoded). Now denomination-
// agnostic with a companion `denomination_fiat` field
// indicating the unit (USD / EUR / XDR / XAU / etc.).
// See ADR-0040.
base_fee_fiat: z.number().optional(),
blurt_price_fiat: z.number().optional(),
denomination_fiat: z.string().optional(),
price_warning: z.string().optional()
});
const ReleaseSchema = z.object({
version: z.string(),
hash_manifest: z.unknown(),
endpoints: z.unknown(),
signer: z.string(),
source_trx_id: z.string(),
source_block_num: z.number(),
created_at: z.string()
});
const ErrorResponseSchema = z.object({
status: z.literal('error'),
code: z.string(), // ErrorCode is a string union; relaxed to z.string() since
// the exact union changes faster than this schema.
message: z.string()
});
const OperatorStatsSchema = z.object({
probe_status: z.enum(['ok', 'degraded', 'down', 'unknown']),
probe_latency_ms: z.number().nullable(),
probe_checked_at: z.string().nullable(),
live_orders_total: z.number(),
live_orders_24h: z.number(),
feedback_total: z.number(),
feedback_positive_pct: z.number().nullable()
});
const OperatorRecordSchema = z.object({
account: z.string(),
tag: z.string(),
display_name: z.string(),
contact_url: z.string().nullable(),
registered_at: z.string(),
is_active: z.boolean(),
stats: OperatorStatsSchema.optional()
});
const AltNetworksSchema = z.object({
tor: z.string().nullable(),
lokinet: z.string().nullable(),
i2p_b32: z.string().nullable(),
i2p_name: z.string().nullable(),
ens: z.string().nullable().optional(),
i2p: z.string().nullable().optional(),
nostr: z.string().nullable()
});
const SeoSchema = z.object({
title: z.string().nullable(),
description: z.string().nullable(),
keywords: z.string().nullable()
});
const ChatLinkUrlsSchema = z.object({
btc: z.string().nullable(),
xmr: z.string().nullable(),
// Part 122 cp21 — BCH chat-link URL override. Optional for
// back-compat with pre-cp21 indexer builds.
bch: z.string().nullable().optional(),
// Part 122 cp24 — LTC chat-link URL override. Optional for
// back-compat with pre-cp24 indexer builds.
ltc: z.string().nullable().optional(),
// Part 122 cp27 — DASH chat-link URL override. Optional for
// back-compat with pre-cp27 indexer builds.
dash: z.string().nullable().optional(),
// Part 122 cp33 — DOGE chat-link URL override. Optional for
// back-compat with pre-cp33 indexer builds.
doge: z.string().nullable().optional(),
// Part 122 cp39 — ZEC chat-link URL override. Optional for
// back-compat with pre-cp39 indexer builds.
zec: z.string().nullable().optional(),
arrr: z.string().nullable().optional(),
dcr: z.string().nullable().optional(),
sol: z.string().nullable().optional(),
eth: z.string().nullable().optional(),
xrp: z.string().nullable().optional(),
usdt: z
.object({
erc20: z.string().nullable(),
trc20: z.string().nullable(),
spl: z.string().nullable(),
bep20: z.string().nullable()
})
.optional(),
// Part 122 cp30 — USDC per-network chat-link URL overrides.
// Same back-compat optionality as USDT — pre-cp30 indexer
// builds may omit this field entirely.
usdc: z
.object({
erc20: z.string().nullable(),
spl: z.string().nullable(),
base: z.string().nullable(),
polygon: z.string().nullable()
})
.optional(),
// Part 122 cp31 — DAI per-network chat-link URL overrides.
// 4 EVM networks: ERC-20, Polygon, Base, Arbitrum. No SPL
// per ADR-0029 §1 (no canonical Maker DAI on Solana).
// Same back-compat optionality as USDC.
dai: z
.object({
erc20: z.string().nullable(),
polygon: z.string().nullable(),
base: z.string().nullable(),
arbitrum: z.string().nullable()
})
.optional()
});
const InstanceResponseSchema = z
.object({
name: z.string().nullable(),
tagline: z.string().nullable(),
contact_url: z.string().nullable(),
alt_networks: AltNetworksSchema,
fee_recipient: z.string(),
relay_account: z.string(),
operator_tag: z.string().nullable().optional(),
seo: SeoSchema.optional(),
chat_link_urls: ChatLinkUrlsSchema.optional(),
disabled_assets: z.array(z.string()).optional()
})
.passthrough(); // The real InstanceResponse has many more optional fields;
// .passthrough() allows them so a snapshot fixture stays
// valid as new fields are added.
const InstanceDirectoryEntrySchema = z
.object({
origin: z.string(),
operator_account: z.string(),
operator_tag: z.string().nullable(),
operator_display_name: z.string().nullable(),
name: z.string().nullable(),
tagline: z.string().nullable(),
contact_url: z.string().nullable(),
alt_networks: z.unknown(), // shape varies; passthrough-tolerant
status: z.enum(['good', 'quiet', 'stale', 'unreachable', 'mismatch']),
registered_at: z.string(),
last_probed_at: z.string().nullable(),
indexed_block: z.number().nullable(),
chain_lag_sec: z.number().nullable(),
consecutive_failures: z.number()
})
.passthrough();
const OrderRecordSchema = z
.object({
account: z.string(),
permlink: z.string(),
side: z.enum(['buy', 'sell']),
asset: z.string(), // AssetTicker is a string union; relaxed
fiat_currency: z.string(),
amount_min: z.number().nullable(),
amount_max: z.number().nullable(),
price_model: z.unknown(),
location_region: z.string().nullable(),
payment_methods: z.array(z.string()),
terms: z.string().nullable(),
status: z.enum(['live', 'cancelled', 'expired']).optional(),
fee_status: z.string().optional(),
created_at: z.string(),
updated_at: z.string(),
expires_at: z.string().nullable()
})
.passthrough();
const FeedbackSummarySchema = z.object({
count: z.number(),
weighted_rating: z.number(),
by_rating: z.object({
'1': z.number(),
'2': z.number(),
'3': z.number(),
'4': z.number(),
'5': z.number()
})
});
const ChatAdmissionSchema = z
.object({
me: z.string(),
peer: z.string(),
admitted: z.boolean(),
reason: z.enum(['prior_exchange', 'fee_paid', 'none'])
})
.passthrough(); // Forward-compat for future fields.
// ─── cp16 schemas (extend coverage to more interfaces) ─────────
const OrderViewsResponseSchema = z.object({
count: z.number(),
updated_at: z.string().nullable()
});
const OrderViewIncrementResponseSchema = z.object({
count: z.number()
});
const OrderbookResponseSchema = z.object({
items: z.array(OrderRecordSchema),
next_cursor: z.string().nullable(),
indexed_block: z.number()
});
const FeaturedBidSchema = z.object({
hours_requested: z.number(),
blurt_paid: z.string(),
blurt_per_hour: z.string(),
effective_at: z.string(),
expires_at: z.string()
});
const FeaturedSlotSchema = z.object({
order: OrderRecordSchema,
bid: FeaturedBidSchema
});
const FeaturedOrderbookResponseSchema = z.object({
featured: z.array(FeaturedSlotSchema),
max_slots: z.number()
});
const AccountOrdersResponseSchema = z.object({
items: z.array(OrderRecordSchema),
next_cursor: z.string().nullable()
});
const ProfileResponseSchema = z.object({
account: z.string(),
display_name: z.string(),
json_metadata: z.unknown(),
source_block_num: z.number(),
updated_at: z.string()
});
const OperatorStatsInnerSchema = z.object({
cumulative_blurt_earned: z.number(),
total_orders_attributed: z.number()
});
const OperatorsResponseSchema = z.object({
operators: z.array(OperatorRecordSchema)
});
const ChatIdentityResponseSchema = z.object({
account: z.string(),
chat_pub: z.string(),
source_block_num: z.number(),
source_trx_id: z.string(),
updated_at: z.string()
});
const ConversationSummarySchema = z.object({
peer: z.string(),
last_message_at: z.string(),
message_count: z.number()
});
const ConversationsResponseSchema = z.object({
account: z.string(),
items: z.array(ConversationSummarySchema)
});
const BlockEntrySchema = z.object({
blocked: z.string(),
since_block_num: z.number(),
since_trx_id: z.string(),
created_at: z.string(),
updated_at: z.string()
});
const BlocksResponseSchema = z.object({
account: z.string(),
items: z.array(BlockEntrySchema)
});
const ChatMessageRecordSchema = z
.object({
id: z.number(),
sender: z.string(),
recipient: z.string(),
ciphertext: z.string(),
header: z.unknown(),
created_at: z.string()
})
.passthrough();
const ChatHistoryResponseSchema = z.object({
items: z.array(ChatMessageRecordSchema),
next_cursor: z.string().nullable()
});
const InstanceDirectoryResponseSchema = z.object({
instances: z.array(InstanceDirectoryEntrySchema)
});
// ─── cp17 schemas (final ~12 lower-traffic types) ──────────────
const ClearingPricePointSchema = z.object({
day: z.string(),
clearing_blurt_per_hour: z.number(),
active_visible_count: z.number(),
max_slots: z.number()
});
const ClearingPriceHistoryResponseSchema = z.object({
points: z.array(ClearingPricePointSchema),
window_days: z.number(),
max_slots: z.number()
});
const BatchProfilesResponseSchema = z.object({
profiles: z.record(z.string(), ProfileResponseSchema)
});
const FeedbackResponseRecordSchema = z.object({
responder: z.string(),
comment: z.string(),
created_at: z.string()
});
const FeedbackRecordSchema = z.object({
id: z.number(),
reviewer: z.string(),
subject: z.string(),
rating: z.union([z.literal(1), z.literal(2), z.literal(3), z.literal(4), z.literal(5)]),
comment: z.string().nullable(),
order_permlink: z.string().nullable(),
created_at: z.string(),
source_trx_id: z.string(),
suppressed: z.boolean().optional(),
has_verified_chat: z.boolean().optional(),
responses: z.array(FeedbackResponseRecordSchema)
});
const FeedbackSummarySchemaForAcct = FeedbackSummarySchema; // reuse
const AccountFeedbackResponseSchema = z.object({
summary: FeedbackSummarySchemaForAcct,
items: z.array(FeedbackRecordSchema),
next_cursor: z.string().nullable()
});
const AccountFeedbackGivenResponseSchema = z.object({
items: z.array(FeedbackRecordSchema),
next_cursor: z.string().nullable()
});
const ChatReadStateEntrySchema = z.object({
peer: z.string(),
last_read_at: z.string()
});
const ChatReadStateResponseSchema = z.object({
account: z.string(),
items: z.array(ChatReadStateEntrySchema)
});
const AttestorEligibilityResponseSchema = z
.object({
account: z.string(),
phase: z.enum(['launch', 'steady']),
eligible: z.boolean(),
reason: z.string(), // AttestorEligibilityReason is a string union
loyalty_blurt: z.number(),
age_days: z.number(),
missing_loyalty_blurt: z.number(),
days_until_eligible: z.number().nullable()
})
.passthrough();
const StrangerFeeQuoteResponseSchema = z.object({
account: z.string(),
base_price_blurt: z.number(),
price_blurt: z.number(),
multiplier: z.number(),
recent_count: z.number(),
window_minutes: z.number(),
capped: z.boolean(),
max_multiplier: z.number()
});
// ─── Sample literals (TS-type-cross-check) ─────────────────────
//
// Each literal is `satisfies` the canonical TS interface from
// @morphit/indexer-client. Goal: typecheck will fail if:
// - a required field is added to the TS interface, OR
// - a required field is removed from the TS interface AND I
// don't update both the schema and the literal.
//
// We use `satisfies` (TS 4.9+) instead of `as` to keep the
// literal narrowly typed AND require structural conformance.
const sampleHealth = {
status: 'ok',
version: '0.1.0',
uptime_sec: 3600,
chain_head_block: 1000,
indexed_block: 999,
lag_blocks: 1,
stale: false
} satisfies HealthResponse;
const sampleListingFee = {
base_fee_blurt: 0.5,
feature_fee_blurt_per_hour: 0.1,
quote_ttl_seconds: 60
} satisfies ListingFeeResponse;
const sampleRelease = {
version: 'v0.1.0-beta.1',
hash_manifest: {},
endpoints: {},
signer: 'morphit',
source_trx_id: 'abc123',
source_block_num: 100,
created_at: '2026-05-14T00:00:00Z'
} satisfies ReleaseResponse;
const sampleError = {
status: 'error',
code: 'not_found',
message: 'order not found'
} satisfies ErrorResponse;
const sampleOperator = {
account: 'morphit-alice',
tag: 'alice',
display_name: 'Alice Operator',
contact_url: null,
registered_at: '2026-05-14T00:00:00Z',
is_active: true
} satisfies OperatorRecord;
const sampleInstance = {
name: 'Morphit Alice',
tagline: null,
contact_url: null,
alt_networks: {
tor: null,
lokinet: null,
i2p_b32: null,
i2p_name: null,
ens: null,
nostr: null
},
fee_recipient: '@morphit-fees',
relay_account: '@morphit-alice'
} satisfies InstanceResponse;
const sampleInstanceDirEntry = {
origin: 'https://morphit.io',
operator_account: 'morphit',
operator_tag: null,
operator_display_name: null,
name: null,
tagline: null,
contact_url: null,
alt_networks: null,
status: 'good' as const,
registered_at: '2026-05-14T00:00:00Z',
last_probed_at: null,
indexed_block: null,
chain_lag_sec: null,
consecutive_failures: 0
} satisfies InstanceDirectoryEntry;
const sampleOrder = {
account: 'alice',
permlink: 'sell-btc-2026-05-14',
side: 'sell' as const,
asset: 'BTC' as const,
fiat_currency: 'USD',
amount_min: 50,
amount_max: 500,
price_model: { type: 'spread', spread_pct: 1.5 },
location_region: 'EU',
payment_methods: ['sepa', 'wise'],
terms: null,
created_at: '2026-05-14T00:00:00Z',
updated_at: '2026-05-14T00:00:00Z',
expires_at: null
} satisfies OrderRecord;
const sampleFeedbackSummary = {
count: 42,
weighted_rating: 4.75,
by_rating: { '1': 0, '2': 1, '3': 1, '4': 5, '5': 35 },
// cp124 H5: by-side breakdown
by_side: {
buy: { count: 25, weighted_rating: 4.92 },
sell: { count: 17, weighted_rating: 4.5 }
},
// cp124 H6: dormancy signal
last_traded_at: '2026-05-15T12:30:00Z'
} satisfies FeedbackSummary;
const sampleChatAdmission = {
me: 'alice',
peer: 'bob',
admitted: true,
reason: 'fee_paid' as const
} satisfies ChatAdmissionResponse;
// ─── cp16 samples ──────────────────────────────────────────────
const sampleOrderViews = {
count: 42,
updated_at: '2026-05-15T00:00:00Z'
} satisfies OrderViewsResponse;
const sampleOrderViewIncrement = {
count: 43
} satisfies OrderViewIncrementResponse;
const sampleOrderbookResponse = {
items: [sampleOrder],
next_cursor: null,
indexed_block: 12345
} satisfies OrderbookResponse;
const sampleFeaturedBid = {
hours_requested: 24,
blurt_paid: '2.40',
blurt_per_hour: '0.10',
effective_at: '2026-05-15T00:00:00Z',
expires_at: '2026-05-16T00:00:00Z'
} satisfies FeaturedBid;
const sampleFeaturedSlot = {
order: sampleOrder,
bid: sampleFeaturedBid
} satisfies FeaturedSlot;
const sampleFeaturedOrderbook = {
featured: [sampleFeaturedSlot],
max_slots: 3
} satisfies FeaturedOrderbookResponse;
const sampleAccountOrders = {
items: [sampleOrder],
next_cursor: null
} satisfies AccountOrdersResponse;
const sampleProfile = {
account: 'alice',
display_name: 'Alice',
json_metadata: { bio: 'P2P trader' },
source_block_num: 12345,
updated_at: '2026-05-15T00:00:00Z'
} satisfies ProfileResponse;
const sampleOperatorStats = {
cumulative_blurt_earned: 100.5,
total_orders_attributed: 50
} satisfies OperatorStats;
const sampleOperators = {
operators: [sampleOperator]
} satisfies OperatorsResponse;
const sampleChatIdentity = {
account: 'alice',
chat_pub: 'aGVsbG8td29ybGQtY2hhdC1wdWJsaWMta2V5LWJhc2U2NA==',
source_block_num: 12345,
source_trx_id: 'abc123def456',
updated_at: '2026-05-15T00:00:00Z'
} satisfies ChatIdentityResponse;
const sampleConversationSummary = {
peer: 'bob',
last_message_at: '2026-05-15T00:00:00Z',
message_count: 5,
// cp425 — ConversationSummary now carries the order the conversation is
// about (ConversationOrderRef | null). Sample a real ref: bob is asking
// alice (the recipient / order owner) about her BTC sell order.
order: {
permlink: 'sell-btc-usd-abc123',
account: 'alice',
side: 'sell',
asset: 'BTC',
fiat_currency: 'USD',
amount_min: 50,
amount_max: 500,
// cp446 — the inbox card shows the order's current state beside "RE:".
status: 'live'
}
} satisfies ConversationSummary;
const sampleConversations = {
account: 'alice',
items: [sampleConversationSummary]
} satisfies ConversationsResponse;
const sampleBlockEntry = {
blocked: 'spammer-1',
since_block_num: 12345,
since_trx_id: 'abc123def456',
created_at: '2026-05-15T00:00:00Z',
updated_at: '2026-05-15T00:00:00Z'
} satisfies BlockEntry;
const sampleBlocks = {
account: 'alice',
items: [sampleBlockEntry]
} satisfies BlocksResponse;
const sampleChatMessage = {
id: 100,
sender: 'alice',
recipient: 'bob',
ciphertext: 'opaque-base64-payload-placeholder',
header: { ephemeral_pub: 'x', nonce: 'y', client_tag: 'z' },
created_at: '2026-05-15T00:00:00Z'
} satisfies ChatMessageRecord;
const sampleChatHistory = {
items: [sampleChatMessage],
next_cursor: null
} satisfies ChatHistoryResponse;
const sampleInstanceDirectory = {
version: 1 as const,
directory_updated_at: '2026-05-15T00:00:00Z',
instances: [sampleInstanceDirEntry]
} satisfies InstanceDirectoryResponse;
// ─── cp17 samples ──────────────────────────────────────────────
const sampleClearingPricePoint = {
day: '2026-05-15',
clearing_blurt_per_hour: 0.12,
active_visible_count: 4,
max_slots: 3
} satisfies ClearingPricePoint;
const sampleClearingPriceHistory = {
points: [sampleClearingPricePoint],
window_days: 30,
max_slots: 3
} satisfies ClearingPriceHistoryResponse;
const sampleBatchProfiles = {
profiles: { alice: sampleProfile, bob: sampleProfile }
} satisfies BatchProfilesResponse;
const sampleFeedbackResponse = {
responder: 'alice',
comment: 'Thanks for the smooth trade!',
created_at: '2026-05-15T00:00:00Z'
} satisfies FeedbackResponseRecord;
const sampleFeedbackRecord = {
id: 100,
reviewer: 'bob',
subject: 'alice',
rating: 5 as const,
comment: 'Smooth trade, fast settlement.',
order_permlink: 'sell-btc-2026-05-14',
created_at: '2026-05-15T00:00:00Z',
source_trx_id: 'abc123def456',
responses: [sampleFeedbackResponse]
} satisfies FeedbackRecord;
const sampleAccountFeedback = {
summary: sampleFeedbackSummary,
items: [sampleFeedbackRecord],
next_cursor: null
} satisfies AccountFeedbackResponse;
const sampleAccountFeedbackGiven = {
items: [sampleFeedbackRecord],
next_cursor: null
} satisfies AccountFeedbackGivenResponse;
const sampleChatReadStateEntry = {
peer: 'bob',
last_read_at: '2026-05-15T00:00:00Z'
} satisfies ChatReadStateEntry;
const sampleChatReadState = {
account: 'alice',
items: [sampleChatReadStateEntry]
} satisfies ChatReadStateResponse;
const sampleAttestorEligibility = {
account: 'alice',
phase: 'launch' as const,
eligible: true,
reason: 'age' as const,
loyalty_blurt: 5,
age_days: 90,
missing_loyalty_blurt: 0,
days_until_eligible: null
} satisfies AttestorEligibilityResponse;
const sampleStrangerFeeQuote = {
account: 'alice',
base_price_blurt: 5,
price_blurt: 10,
multiplier: 2,
recent_count: 2,
window_minutes: 60,
capped: false,
max_multiplier: 128
} satisfies StrangerFeeQuoteResponse;
// ─── Scenarios ─────────────────────────────────────────────────
interface Scenario {
readonly name: string;
readonly schema: z.ZodTypeAny;
readonly valid: unknown;
/** Mutator returns a deliberately-malformed copy that must
* fail validation. */
readonly invalidate: (sample: any) => unknown;
/** Human-readable reason the malformed copy is invalid —
* printed in the failure detail when the smoke catches a
* schema that accepts what it should reject. */
readonly invalidReason: string;
}
const scenarios: Scenario[] = [
{
name: 'HealthResponse',
schema: HealthSchema,
valid: sampleHealth,
invalidate: (s) => ({ ...s, status: 'fubar' }),
invalidReason: "status='fubar' (must be 'ok'|'degraded')"
},
{
name: 'ListingFeeResponse',
schema: ListingFeeSchema,
valid: sampleListingFee,
invalidate: (s) => ({ ...s, base_fee_blurt: 'oops' }),
invalidReason: "base_fee_blurt='oops' (must be number)"
},
{
name: 'ReleaseResponse',
schema: ReleaseSchema,
valid: sampleRelease,
invalidate: (s) => {
const { version, ...rest } = s;
return rest;
},
invalidReason: 'missing required field "version"'
},
{
name: 'ErrorResponse',
schema: ErrorResponseSchema,
valid: sampleError,
invalidate: (s) => ({ ...s, status: 'ok' }),
invalidReason: "status='ok' (must be literal 'error')"
},
{
name: 'OperatorRecord',
schema: OperatorRecordSchema,
valid: sampleOperator,
invalidate: (s) => ({ ...s, is_active: 'yes' }),
invalidReason: "is_active='yes' (must be boolean)"
},
{
name: 'InstanceResponse',
schema: InstanceResponseSchema,
valid: sampleInstance,
invalidate: (s) => ({ ...s, fee_recipient: 12345 }),
invalidReason: 'fee_recipient=12345 (must be string)'
},
{
name: 'InstanceDirectoryEntry',
schema: InstanceDirectoryEntrySchema,
valid: sampleInstanceDirEntry,
invalidate: (s) => ({ ...s, origin: null }),
invalidReason: 'origin=null (must be string)'
},
{
name: 'OrderRecord',
schema: OrderRecordSchema,
valid: sampleOrder,
invalidate: (s) => ({ ...s, side: 'wat' }),
invalidReason: "side='wat' (must be 'buy'|'sell')"
},
{
name: 'FeedbackSummary',
schema: FeedbackSummarySchema,
valid: sampleFeedbackSummary,
invalidate: (s) => {
const { count, ...rest } = s;
return rest;
},
invalidReason: 'missing required field "count"'
},
{
name: 'ChatAdmissionResponse',
schema: ChatAdmissionSchema,
valid: sampleChatAdmission,
invalidate: (s) => ({ ...s, admitted: 'maybe' }),
invalidReason: "admitted='maybe' (must be boolean)"
},
// ─── cp16 scenarios — extended REST coverage ──────────────
{
name: 'OrderViewsResponse',
schema: OrderViewsResponseSchema,
valid: sampleOrderViews,
invalidate: (s) => ({ ...s, count: '42' }),
invalidReason: 'count="42" (must be number)'
},
{
name: 'OrderViewIncrementResponse',
schema: OrderViewIncrementResponseSchema,
valid: sampleOrderViewIncrement,
invalidate: (s) => {
const { count, ...rest } = s;
return rest;
},
invalidReason: 'missing required field "count"'
},
{
name: 'OrderbookResponse',
schema: OrderbookResponseSchema,
valid: sampleOrderbookResponse,
invalidate: (s) => ({ ...s, items: 'not an array' }),
invalidReason: 'items="not an array" (must be array)'
},
{
name: 'FeaturedBid',
schema: FeaturedBidSchema,
valid: sampleFeaturedBid,
invalidate: (s) => ({ ...s, hours_requested: '24' }),
invalidReason: 'hours_requested="24" (must be number — stringy numbers a common mistake)'
},
{
name: 'FeaturedSlot',
schema: FeaturedSlotSchema,
valid: sampleFeaturedSlot,
invalidate: (s) => {
const { bid, ...rest } = s;
return rest;
},
invalidReason: 'missing required field "bid"'
},
{
name: 'FeaturedOrderbookResponse',
schema: FeaturedOrderbookResponseSchema,
valid: sampleFeaturedOrderbook,
invalidate: (s) => ({ ...s, max_slots: null }),
invalidReason: 'max_slots=null (must be number)'
},
{
name: 'AccountOrdersResponse',
schema: AccountOrdersResponseSchema,
valid: sampleAccountOrders,
invalidate: (s) => ({ ...s, next_cursor: 12345 }),
invalidReason: 'next_cursor=12345 (must be string|null)'
},
{
name: 'ProfileResponse',
schema: ProfileResponseSchema,
valid: sampleProfile,
invalidate: (s) => ({ ...s, source_block_num: 'block#12345' }),
invalidReason: 'source_block_num="block#12345" (must be number)'
},
{
name: 'OperatorStats',
schema: OperatorStatsInnerSchema,
valid: sampleOperatorStats,
invalidate: (s) => ({ ...s, cumulative_blurt_earned: 'lots' }),
invalidReason: 'cumulative_blurt_earned="lots" (must be number)'
},
{
name: 'OperatorsResponse',
schema: OperatorsResponseSchema,
valid: sampleOperators,
invalidate: (s) => ({ ...s, operators: 'one operator' }),
invalidReason: 'operators="one operator" (must be array)'
},
{
name: 'ChatIdentityResponse',
schema: ChatIdentityResponseSchema,
valid: sampleChatIdentity,
invalidate: (s) => ({ ...s, chat_pub: null }),
invalidReason: 'chat_pub=null (must be string)'
},
{
name: 'ConversationSummary',
schema: ConversationSummarySchema,
valid: sampleConversationSummary,
invalidate: (s) => ({ ...s, message_count: 'five' }),
invalidReason: 'message_count="five" (must be a number)'
},
{
name: 'ConversationsResponse',
schema: ConversationsResponseSchema,
valid: sampleConversations,
invalidate: (s) => {
const { account, ...rest } = s;
return rest;
},
invalidReason: 'missing required field "account"'
},
{
name: 'BlockEntry',
schema: BlockEntrySchema,
valid: sampleBlockEntry,
invalidate: (s) => ({ ...s, since_block_num: '12345' }),
invalidReason: 'since_block_num="12345" (must be number)'
},
{
name: 'BlocksResponse',
schema: BlocksResponseSchema,
valid: sampleBlocks,
invalidate: (s) => ({ ...s, account: false }),
invalidReason: 'account=false (must be string)'
},
{
name: 'ChatHistoryResponse',
schema: ChatHistoryResponseSchema,
valid: sampleChatHistory,
invalidate: (s) => ({ ...s, items: null }),
invalidReason: 'items=null (must be array)'
},
{
name: 'InstanceDirectoryResponse',
schema: InstanceDirectoryResponseSchema,
valid: sampleInstanceDirectory,
invalidate: (s) => {
const { instances, ...rest } = s;
return rest;
},
invalidReason: 'missing required field "instances"'
},
// ─── cp17 scenarios — final lower-traffic types ───────────
{
name: 'ClearingPricePoint',
schema: ClearingPricePointSchema,
valid: sampleClearingPricePoint,
invalidate: (s) => ({ ...s, day: null }),
invalidReason: 'day=null (must be string)'
},
{
name: 'ClearingPriceHistoryResponse',
schema: ClearingPriceHistoryResponseSchema,
valid: sampleClearingPriceHistory,
invalidate: (s) => ({ ...s, window_days: 'thirty' }),
invalidReason: 'window_days="thirty" (must be number)'
},
{
name: 'BatchProfilesResponse',
schema: BatchProfilesResponseSchema,
valid: sampleBatchProfiles,
invalidate: (s) => ({ ...s, profiles: 'not a record' }),
invalidReason: 'profiles="not a record" (must be record)'
},
{
name: 'FeedbackResponseRecord',
schema: FeedbackResponseRecordSchema,
valid: sampleFeedbackResponse,
invalidate: (s) => ({ ...s, comment: null }),
invalidReason: 'comment=null (must be string; FeedbackResponseRecord requires non-null)'
},
{
name: 'FeedbackRecord',
schema: FeedbackRecordSchema,
valid: sampleFeedbackRecord,
invalidate: (s) => ({ ...s, rating: 6 }),
invalidReason: 'rating=6 (must be 1|2|3|4|5)'
},
{
name: 'AccountFeedbackResponse',
schema: AccountFeedbackResponseSchema,
valid: sampleAccountFeedback,
invalidate: (s) => {
const { summary, ...rest } = s;
return rest;
},
invalidReason: 'missing required field "summary"'
},
{
name: 'AccountFeedbackGivenResponse',
schema: AccountFeedbackGivenResponseSchema,
valid: sampleAccountFeedbackGiven,
invalidate: (s) => ({ ...s, items: { not: 'an array' } }),
invalidReason: 'items=object (must be array)'
},
{
name: 'ChatReadStateEntry',
schema: ChatReadStateEntrySchema,
valid: sampleChatReadStateEntry,
invalidate: (s) => ({ ...s, last_read_at: 12345 }),
invalidReason: 'last_read_at=12345 (must be ISO string, not epoch number)'
},
{
name: 'ChatReadStateResponse',
schema: ChatReadStateResponseSchema,
valid: sampleChatReadState,
invalidate: (s) => ({ ...s, account: null }),
invalidReason: 'account=null (must be string)'
},
{
name: 'AttestorEligibilityResponse',
schema: AttestorEligibilityResponseSchema,
valid: sampleAttestorEligibility,
invalidate: (s) => ({ ...s, phase: 'beta' }),
invalidReason: "phase='beta' (must be 'launch'|'steady')"
},
{
name: 'StrangerFeeQuoteResponse',
schema: StrangerFeeQuoteResponseSchema,
valid: sampleStrangerFeeQuote,
invalidate: (s) => ({ ...s, capped: 'yes' }),
invalidReason: "capped='yes' (must be boolean)"
}
];
// ─── Run scenarios ─────────────────────────────────────────────
// Each scenario contributes TWO checks:
// - the sample literal must parse OK
// - the mutated copy must FAIL to parse
// So total = scenarios.length * 2.
console.log(`api-response-shape smoke: ${scenarios.length * 2} checks\n`);
let failed = 0;
for (const s of scenarios) {
const validResult = s.schema.safeParse(s.valid);
if (validResult.success) {
console.log(`${s.name} valid sample parses`);
} else {
const issues = validResult.error.issues
.map((i) => `${i.path.join('.')}: ${i.message}`)
.join('; ');
console.log(`${s.name} valid sample FAILED: ${issues}`);
failed++;
}
const invalidSample = s.invalidate(s.valid as Record<string, unknown>);
const invalidResult = s.schema.safeParse(invalidSample);
if (!invalidResult.success) {
console.log(`${s.name} rejects ${s.invalidReason}`);
} else {
console.log(`${s.name} should have rejected ${s.invalidReason}`);
failed++;
}
}
console.log('');
if (failed === 0) {
console.log(`✓ all ${scenarios.length * 2} api-response-shape checks hold`);
process.exit(0);
}
console.error(
`${failed} failed, ${scenarios.length * 2 - failed} passed`
);
process.exit(1);