@operor/testing 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,267 @@
1
+ import type { Operor } from '@operor/core';
2
+ import type { MockProvider } from '@operor/provider-mock';
3
+ import type {
4
+ ConversationScenario,
5
+ ConversationTurn,
6
+ ConversationTestResult,
7
+ } from './types.js';
8
+ import { CustomerSimulator } from './CustomerSimulator.js';
9
+ import { ConversationEvaluator } from './ConversationEvaluator.js';
10
+ import { formatTimestamp } from './utils.js';
11
+
12
+ export interface ConversationRunnerConfig {
13
+ agentOS: Operor;
14
+ customerSimulator: CustomerSimulator;
15
+ conversationEvaluator: ConversationEvaluator;
16
+ timeout?: number;
17
+ verbose?: boolean;
18
+ }
19
+
20
+ export class ConversationRunner {
21
+ private agentOS: Operor;
22
+ private customerSimulator: CustomerSimulator;
23
+ private conversationEvaluator: ConversationEvaluator;
24
+ private timeout: number;
25
+ private verbose: boolean;
26
+
27
+ constructor(config: ConversationRunnerConfig) {
28
+ this.agentOS = config.agentOS;
29
+ this.customerSimulator = config.customerSimulator;
30
+ this.conversationEvaluator = config.conversationEvaluator;
31
+ this.timeout = config.timeout || 30000;
32
+ this.verbose = config.verbose ?? false;
33
+ }
34
+
35
+ async runScenario(scenario: ConversationScenario): Promise<ConversationTestResult> {
36
+ const startTime = Date.now();
37
+ const turns: ConversationTurn[] = [];
38
+ const toolsCalled: Array<{ name: string; params: any; result: any }> = [];
39
+ let totalCost = 0;
40
+
41
+ try {
42
+ // Get MockProvider
43
+ const mockProvider = this.getMockProvider();
44
+ const customerId = `test-customer-${scenario.id}`;
45
+
46
+ // Start conversation with initial message (from scenario or first scripted response)
47
+ const initialMessage = scenario.scriptedResponses?.[0] || 'Hello, I need help';
48
+
49
+ if (this.verbose) {
50
+ console.log(`\n=== Starting Scenario: ${scenario.name} ===`);
51
+ console.log(`Persona: ${scenario.persona}`);
52
+ console.log(`Max Turns: ${scenario.maxTurns}\n`);
53
+ }
54
+
55
+ let shouldContinue = true;
56
+ let currentTurn = 0;
57
+
58
+ while (shouldContinue && currentTurn < scenario.maxTurns) {
59
+ // Determine customer message
60
+ const customerMessage =
61
+ currentTurn === 0
62
+ ? initialMessage
63
+ : (
64
+ await this.customerSimulator.generateMessage(scenario.persona, turns, {
65
+ scenario: scenario.description,
66
+ maxTurns: scenario.maxTurns,
67
+ currentTurn,
68
+ scriptedResponses: scenario.scriptedResponses,
69
+ })
70
+ ).message;
71
+
72
+ if (this.verbose) {
73
+ console.log(`[${formatTimestamp()}] Turn ${currentTurn + 1} [customer]: ${customerMessage}`);
74
+ }
75
+
76
+ // Wait for agent response
77
+ const agentResponse = await this.waitForAgentResponse(
78
+ mockProvider,
79
+ customerId,
80
+ customerMessage
81
+ );
82
+
83
+ if (this.verbose) {
84
+ console.log(`[${formatTimestamp()}] Turn ${currentTurn + 1} [agent]: ${agentResponse.text}`);
85
+ if (agentResponse.toolCalls?.length) {
86
+ console.log(
87
+ ` Tools called: ${agentResponse.toolCalls.map((tc) => tc.name).join(', ')}`
88
+ );
89
+ }
90
+ }
91
+
92
+ // Record turn
93
+ turns.push({
94
+ role: 'customer',
95
+ message: customerMessage,
96
+ });
97
+ turns.push({
98
+ role: 'agent',
99
+ message: agentResponse.text,
100
+ toolCalls: agentResponse.toolCalls,
101
+ });
102
+
103
+ // Track tools and cost
104
+ if (agentResponse.toolCalls) {
105
+ toolsCalled.push(...agentResponse.toolCalls);
106
+ }
107
+ totalCost += agentResponse.cost || 0;
108
+
109
+ currentTurn++;
110
+
111
+ // Check if conversation should continue
112
+ if (scenario.scriptedResponses) {
113
+ // Script mode: continue until scripts exhausted
114
+ shouldContinue = currentTurn < scenario.scriptedResponses.length;
115
+ } else {
116
+ // LLM mode: ask CustomerSimulator
117
+ if (currentTurn < scenario.maxTurns) {
118
+ const nextResponse = await this.customerSimulator.generateMessage(
119
+ scenario.persona,
120
+ turns,
121
+ {
122
+ scenario: scenario.description,
123
+ maxTurns: scenario.maxTurns,
124
+ currentTurn,
125
+ }
126
+ );
127
+ shouldContinue = nextResponse.shouldContinue;
128
+ if (!shouldContinue && this.verbose) {
129
+ console.log('Customer satisfied, ending conversation.');
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ if (this.verbose && currentTurn >= scenario.maxTurns) {
136
+ console.log(`\nReached max turns (${scenario.maxTurns})`);
137
+ }
138
+
139
+ // Evaluate conversation
140
+ const evaluation = await this.conversationEvaluator.evaluate({
141
+ scenario: scenario.description,
142
+ persona: scenario.persona,
143
+ turns,
144
+ toolsCalled,
145
+ expectedTools: scenario.expectedTools,
146
+ expectedOutcome: scenario.expectedOutcome,
147
+ successCriteria: scenario.successCriteria,
148
+ });
149
+
150
+ if (this.verbose) {
151
+ console.log(`\n=== Evaluation ===`);
152
+ console.log(`Overall: ${evaluation.overall}`);
153
+ console.log(`Scores: ${JSON.stringify(evaluation.scores)}`);
154
+ console.log(`Feedback: ${evaluation.feedback}\n`);
155
+ }
156
+
157
+ const duration = Date.now() - startTime;
158
+ const passed = evaluation.overall === 'pass';
159
+
160
+ return {
161
+ scenario,
162
+ passed,
163
+ turns,
164
+ evaluation,
165
+ duration,
166
+ cost: totalCost,
167
+ };
168
+ } catch (error) {
169
+ const duration = Date.now() - startTime;
170
+ const errorMessage = error instanceof Error ? error.message : String(error);
171
+
172
+ if (this.verbose) {
173
+ console.error(`\nError in scenario ${scenario.name}: ${errorMessage}\n`);
174
+ }
175
+
176
+ return {
177
+ scenario,
178
+ passed: false,
179
+ turns,
180
+ evaluation: {
181
+ overall: 'fail',
182
+ scores: { accuracy: 1, toolUsage: 1, tone: 1, resolution: 1 },
183
+ feedback: `Error: ${errorMessage}`,
184
+ },
185
+ duration,
186
+ cost: totalCost,
187
+ };
188
+ }
189
+ }
190
+
191
+ async runScenarios(scenarios: ConversationScenario[]): Promise<ConversationTestResult[]> {
192
+ const results: ConversationTestResult[] = [];
193
+
194
+ for (const scenario of scenarios) {
195
+ const result = await this.runScenario(scenario);
196
+ results.push(result);
197
+ }
198
+
199
+ return results;
200
+ }
201
+
202
+ private getMockProvider(): MockProvider {
203
+ const mockProvider = Array.from((this.agentOS as any).providers.values()).find(
204
+ (p: any) => p.name === 'mock'
205
+ ) as MockProvider | undefined;
206
+
207
+ if (!mockProvider) {
208
+ throw new Error('MockProvider not found in Operor. Add it with agentOS.addProvider()');
209
+ }
210
+
211
+ return mockProvider;
212
+ }
213
+
214
+ private async waitForAgentResponse(
215
+ mockProvider: MockProvider,
216
+ customerId: string,
217
+ message: string
218
+ ): Promise<{ text: string; toolCalls?: Array<{ name: string; params: any; result: any }>; cost: number }> {
219
+ return new Promise((resolve, reject) => {
220
+ let settled = false;
221
+
222
+ const cleanup = () => {
223
+ this.agentOS.removeListener('message:processed', onProcessed);
224
+ this.agentOS.removeListener('error', onError);
225
+ };
226
+
227
+ const timeoutId = setTimeout(() => {
228
+ if (!settled) {
229
+ settled = true;
230
+ cleanup();
231
+ reject(new Error(`Agent response timed out after ${this.timeout}ms`));
232
+ }
233
+ }, this.timeout);
234
+
235
+ const onProcessed = (event: any) => {
236
+ if (!settled) {
237
+ settled = true;
238
+ clearTimeout(timeoutId);
239
+ cleanup();
240
+ resolve({
241
+ text: event.response.text,
242
+ toolCalls: event.response.toolCalls || [],
243
+ cost: event.cost || 0,
244
+ });
245
+ }
246
+ };
247
+
248
+ const onError = (event: any) => {
249
+ if (!settled) {
250
+ settled = true;
251
+ clearTimeout(timeoutId);
252
+ cleanup();
253
+ reject(event.error);
254
+ }
255
+ };
256
+
257
+ // Listen for the response
258
+ this.agentOS.once('message:processed', onProcessed);
259
+
260
+ // Listen for errors
261
+ this.agentOS.once('error', onError);
262
+
263
+ // Simulate incoming message
264
+ mockProvider.simulateIncomingMessage(customerId, message);
265
+ });
266
+ }
267
+ }
@@ -0,0 +1,106 @@
1
+ import type { LLMProvider, LLMMessage } from '@operor/llm';
2
+ import type { ConversationTurn, CustomerSimulatorResponse } from './types.js';
3
+
4
+ const PERSONA_PROMPTS: Record<string, string> = {
5
+ polite: 'You are polite, patient, and use courteous language.',
6
+ frustrated: 'You are frustrated and impatient. You express dissatisfaction but remain civil.',
7
+ confused: 'You are confused and unsure. You ask clarifying questions and sometimes misunderstand.',
8
+ terse: 'You give very short, minimal responses. One sentence max.',
9
+ verbose: 'You are detailed and talkative. You provide lots of context and background.',
10
+ };
11
+
12
+ function buildSystemPrompt(
13
+ persona: string,
14
+ context?: { scenario?: string; maxTurns?: number; currentTurn?: number }
15
+ ): string {
16
+ const style = PERSONA_PROMPTS[persona] || `You are a customer with a ${persona} communication style.`;
17
+ const scenarioLine = context?.scenario ? `\nScenario: ${context.scenario}` : '';
18
+ const turnInfo = context?.maxTurns
19
+ ? `\nThis conversation has a maximum of ${context.maxTurns} turns. You are on turn ${context.currentTurn ?? 1}.`
20
+ : '';
21
+
22
+ return `You are simulating a customer in a support conversation for testing purposes.
23
+ ${style}${scenarioLine}${turnInfo}
24
+
25
+ Rules:
26
+ - Stay in character throughout the conversation.
27
+ - Escalate naturally if your issue isn't being resolved.
28
+ - Set shouldContinue to false when your issue is resolved or you have no more questions.
29
+ - If the agent asks a question, answer it in character.
30
+
31
+ Respond with ONLY valid JSON (no markdown, no code fences):
32
+ {"message": "your response as the customer", "shouldContinue": true}`;
33
+ }
34
+
35
+ function formatHistory(history: ConversationTurn[]): LLMMessage[] {
36
+ return history.map((turn) => ({
37
+ role: turn.role === 'customer' ? 'user' as const : 'assistant' as const,
38
+ content: turn.message,
39
+ }));
40
+ }
41
+
42
+ function parseResponse(text: string): CustomerSimulatorResponse {
43
+ // Try to extract JSON from the response, handling markdown fences
44
+ const cleaned = text.replace(/```(?:json)?\s*/g, '').replace(/```/g, '').trim();
45
+ try {
46
+ const parsed = JSON.parse(cleaned);
47
+ return {
48
+ message: String(parsed.message ?? ''),
49
+ shouldContinue: Boolean(parsed.shouldContinue),
50
+ };
51
+ } catch {
52
+ // If JSON parsing fails, treat the whole text as the message
53
+ return { message: text.trim(), shouldContinue: true };
54
+ }
55
+ }
56
+
57
+ export class CustomerSimulator {
58
+ private llm?: LLMProvider;
59
+
60
+ constructor(options?: { llmProvider?: LLMProvider }) {
61
+ this.llm = options?.llmProvider;
62
+ }
63
+
64
+ async generateMessage(
65
+ persona: string,
66
+ history: ConversationTurn[],
67
+ context?: {
68
+ scenario?: string;
69
+ maxTurns?: number;
70
+ currentTurn?: number;
71
+ scriptedResponses?: string[];
72
+ }
73
+ ): Promise<CustomerSimulatorResponse> {
74
+ // Script mode: use pre-defined responses
75
+ if (context?.scriptedResponses?.length) {
76
+ const turn = context.currentTurn ?? history.filter((t) => t.role === 'customer').length;
77
+ const responses = context.scriptedResponses;
78
+ if (turn < responses.length) {
79
+ return {
80
+ message: responses[turn],
81
+ shouldContinue: turn < responses.length - 1,
82
+ };
83
+ }
84
+ // Exhausted scripted responses
85
+ return { message: responses[responses.length - 1], shouldContinue: false };
86
+ }
87
+
88
+ // LLM mode
89
+ if (!this.llm) {
90
+ throw new Error('CustomerSimulator requires an LLM provider for non-scripted mode');
91
+ }
92
+
93
+ const systemPrompt = buildSystemPrompt(persona, context);
94
+ const messages: LLMMessage[] = [
95
+ { role: 'system', content: systemPrompt },
96
+ ...formatHistory(history),
97
+ ];
98
+
99
+ const result = await this.llm.complete(messages, {
100
+ temperature: 0.7,
101
+ maxTokens: 500,
102
+ });
103
+
104
+ return parseResponse(result.text);
105
+ }
106
+ }
@@ -0,0 +1,336 @@
1
+ import type { Skill, Tool } from '@operor/core';
2
+
3
+ interface MockOrder {
4
+ id: string;
5
+ name: string;
6
+ status: string;
7
+ financialStatus: string;
8
+ createdAt: string;
9
+ expectedDelivery: Date;
10
+ actualDelivery?: Date;
11
+ tracking?: string;
12
+ trackingUrl?: string;
13
+ items: Array<{ name: string; quantity: number; price: string }>;
14
+ total: string;
15
+ }
16
+
17
+ interface MockProduct {
18
+ id: number;
19
+ title: string;
20
+ vendor: string;
21
+ type: string;
22
+ price: string;
23
+ available: boolean;
24
+ }
25
+
26
+ interface MockDiscount {
27
+ code: string;
28
+ percent: number;
29
+ validDays: number;
30
+ startsAt: string;
31
+ expiresAt: string;
32
+ priceRuleId: number;
33
+ createdAt: Date;
34
+ }
35
+
36
+ export class MockShopifySkill implements Skill {
37
+ public readonly name = 'shopify';
38
+ private ready = false;
39
+ private mockOrders: Map<string, MockOrder> = new Map();
40
+ private mockProducts: MockProduct[] = [];
41
+ private mockDiscounts: MockDiscount[] = [];
42
+ private nextPriceRuleId = 1000;
43
+
44
+ constructor() {
45
+ // Seed with mock data
46
+ this.seedMockData();
47
+ }
48
+
49
+ async initialize(): Promise<void> {
50
+ this.ready = true;
51
+ console.log('✅ Mock Shopify initialized');
52
+ }
53
+
54
+ /** @deprecated Use initialize() instead. */
55
+ async authenticate(): Promise<void> {
56
+ return this.initialize();
57
+ }
58
+
59
+ isReady(): boolean {
60
+ return this.ready;
61
+ }
62
+
63
+ /** @deprecated Use isReady() instead. */
64
+ isAuthenticated(): boolean {
65
+ return this.ready;
66
+ }
67
+
68
+ /**
69
+ * Reset all mock data to initial state (for testing)
70
+ */
71
+ reset(): void {
72
+ this.mockOrders.clear();
73
+ this.mockProducts = [];
74
+ this.mockDiscounts = [];
75
+ this.nextPriceRuleId = 1000;
76
+ this.seedMockData();
77
+ }
78
+
79
+ /**
80
+ * Seed custom test data (for testing)
81
+ */
82
+ seedData(config: {
83
+ orders?: Array<Partial<MockOrder>>;
84
+ products?: Array<Partial<MockProduct>>;
85
+ discounts?: Array<Partial<MockDiscount>>;
86
+ }): void {
87
+ if (config.orders) {
88
+ for (const order of config.orders) {
89
+ const fullOrder: MockOrder = {
90
+ id: order.id || String(Date.now()),
91
+ name: order.name || `#${order.id || '1001'}`,
92
+ status: order.status || 'unfulfilled',
93
+ financialStatus: order.financialStatus || 'paid',
94
+ createdAt: order.createdAt || new Date().toISOString(),
95
+ expectedDelivery: order.expectedDelivery || new Date(),
96
+ actualDelivery: order.actualDelivery,
97
+ tracking: order.tracking,
98
+ trackingUrl: order.trackingUrl,
99
+ items: order.items || [],
100
+ total: order.total || '0.00',
101
+ };
102
+ this.mockOrders.set(fullOrder.id, fullOrder);
103
+ }
104
+ }
105
+
106
+ if (config.products) {
107
+ for (const product of config.products) {
108
+ const fullProduct: MockProduct = {
109
+ id: product.id || Date.now(),
110
+ title: product.title || 'Product',
111
+ vendor: product.vendor || 'Mock Vendor',
112
+ type: product.type || 'General',
113
+ price: product.price || '0.00',
114
+ available: product.available !== undefined ? product.available : true,
115
+ };
116
+ this.mockProducts.push(fullProduct);
117
+ }
118
+ }
119
+
120
+ if (config.discounts) {
121
+ for (const discount of config.discounts) {
122
+ const fullDiscount: MockDiscount = {
123
+ code: discount.code || 'DISCOUNT',
124
+ percent: discount.percent || 10,
125
+ validDays: discount.validDays || 30,
126
+ startsAt: discount.startsAt || new Date().toISOString(),
127
+ expiresAt: discount.expiresAt || new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString(),
128
+ priceRuleId: discount.priceRuleId || this.nextPriceRuleId++,
129
+ createdAt: discount.createdAt || new Date(),
130
+ };
131
+ this.mockDiscounts.push(fullDiscount);
132
+ }
133
+ }
134
+ }
135
+
136
+ private seedMockData(): void {
137
+ // Create a delayed order for testing
138
+ const twoDaysAgo = new Date();
139
+ twoDaysAgo.setDate(twoDaysAgo.getDate() - 2);
140
+
141
+ this.mockOrders.set('12345', {
142
+ id: '12345',
143
+ name: '#1001',
144
+ status: 'in_transit',
145
+ financialStatus: 'paid',
146
+ createdAt: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000).toISOString(),
147
+ expectedDelivery: twoDaysAgo,
148
+ tracking: 'TRACK123456789',
149
+ trackingUrl: 'https://track.example.com/TRACK123456789',
150
+ items: [
151
+ { name: 'Premium Headphones', quantity: 1, price: '299.99' },
152
+ ],
153
+ total: '299.99',
154
+ });
155
+
156
+ this.mockOrders.set('67890', {
157
+ id: '67890',
158
+ name: '#1002',
159
+ status: 'delivered',
160
+ financialStatus: 'paid',
161
+ createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000).toISOString(),
162
+ expectedDelivery: new Date(Date.now() - 5 * 24 * 60 * 60 * 1000),
163
+ actualDelivery: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000),
164
+ tracking: 'TRACK987654321',
165
+ trackingUrl: 'https://track.example.com/TRACK987654321',
166
+ items: [
167
+ { name: 'Wireless Mouse', quantity: 2, price: '49.99' },
168
+ ],
169
+ total: '99.98',
170
+ });
171
+
172
+ // Seed default products
173
+ this.mockProducts = [
174
+ {
175
+ id: 1001,
176
+ title: 'Premium Headphones',
177
+ vendor: 'AudioTech',
178
+ type: 'Electronics',
179
+ price: '299.99',
180
+ available: true,
181
+ },
182
+ {
183
+ id: 1002,
184
+ title: 'Wireless Mouse',
185
+ vendor: 'TechGear',
186
+ type: 'Electronics',
187
+ price: '49.99',
188
+ available: true,
189
+ },
190
+ {
191
+ id: 1003,
192
+ title: 'Mechanical Keyboard',
193
+ vendor: 'KeyMaster',
194
+ type: 'Electronics',
195
+ price: '149.99',
196
+ available: true,
197
+ },
198
+ {
199
+ id: 1004,
200
+ title: 'USB-C Cable',
201
+ vendor: 'CableCo',
202
+ type: 'Accessories',
203
+ price: '19.99',
204
+ available: false,
205
+ },
206
+ ];
207
+ }
208
+
209
+ public tools: Record<string, Tool> = {
210
+ get_order: {
211
+ name: 'get_order',
212
+ description: 'Get order details by order ID or order name (e.g., #1001)',
213
+ parameters: {
214
+ orderId: { type: 'string', required: true },
215
+ },
216
+ execute: async (params: { orderId: string }) => {
217
+ const orderIdentifier = params.orderId.replace('#', '');
218
+ const order = this.mockOrders.get(orderIdentifier);
219
+
220
+ if (!order) {
221
+ return {
222
+ found: false,
223
+ error: `Order ${params.orderId} not found`,
224
+ };
225
+ }
226
+
227
+ const delayMs = new Date().getTime() - order.expectedDelivery.getTime();
228
+ const delayDays = Math.floor(delayMs / (1000 * 60 * 60 * 24));
229
+
230
+ return {
231
+ found: true,
232
+ id: order.id,
233
+ name: order.name,
234
+ status: order.status,
235
+ financialStatus: order.financialStatus,
236
+ createdAt: order.createdAt,
237
+ total: order.total,
238
+ items: order.items,
239
+ tracking: order.tracking || null,
240
+ trackingUrl: order.trackingUrl || null,
241
+ // Mock-specific extra fields
242
+ isDelayed: delayDays > 0,
243
+ delayDays: Math.max(0, delayDays),
244
+ };
245
+ },
246
+ },
247
+
248
+ create_discount: {
249
+ name: 'create_discount',
250
+ description: 'Create a percentage discount code',
251
+ parameters: {
252
+ percent: { type: 'number', required: true },
253
+ validDays: { type: 'number', required: true },
254
+ },
255
+ execute: async (params: { percent: number; validDays: number }) => {
256
+ const code = `SORRY${params.percent}`;
257
+ const startsAt = new Date().toISOString();
258
+ const expiresAt = new Date(Date.now() + params.validDays * 24 * 60 * 60 * 1000).toISOString();
259
+ const priceRuleId = this.nextPriceRuleId++;
260
+
261
+ const discount: MockDiscount = {
262
+ code,
263
+ percent: params.percent,
264
+ validDays: params.validDays,
265
+ startsAt,
266
+ expiresAt,
267
+ priceRuleId,
268
+ createdAt: new Date(),
269
+ };
270
+
271
+ this.mockDiscounts.push(discount);
272
+
273
+ console.log(`\n💰 Created discount code: ${code}`);
274
+ console.log(` ${params.percent}% off, valid for ${params.validDays} days\n`);
275
+
276
+ return {
277
+ code,
278
+ percent: params.percent,
279
+ validDays: params.validDays,
280
+ startsAt,
281
+ expiresAt,
282
+ priceRuleId,
283
+ };
284
+ },
285
+ },
286
+
287
+ search_products: {
288
+ name: 'search_products',
289
+ description: 'Search for products in the store',
290
+ parameters: {
291
+ query: { type: 'string', required: true },
292
+ limit: { type: 'number', required: false },
293
+ },
294
+ execute: async (params: { query: string; limit?: number }) => {
295
+ const limit = params.limit || 10;
296
+ const queryLower = params.query.toLowerCase();
297
+
298
+ const matched = this.mockProducts
299
+ .filter((p) =>
300
+ p.title.toLowerCase().includes(queryLower) ||
301
+ p.vendor.toLowerCase().includes(queryLower) ||
302
+ p.type.toLowerCase().includes(queryLower)
303
+ )
304
+ .slice(0, limit);
305
+
306
+ return {
307
+ found: matched.length,
308
+ products: matched.map((p) => ({
309
+ id: p.id,
310
+ title: p.title,
311
+ vendor: p.vendor,
312
+ type: p.type,
313
+ price: p.price,
314
+ available: p.available,
315
+ })),
316
+ };
317
+ },
318
+ },
319
+ };
320
+
321
+ /**
322
+ * Get all created discounts (for testing)
323
+ */
324
+ getDiscounts(): MockDiscount[] {
325
+ return [...this.mockDiscounts];
326
+ }
327
+
328
+ /**
329
+ * Get all orders (for testing)
330
+ */
331
+ getOrders(): MockOrder[] {
332
+ return Array.from(this.mockOrders.values());
333
+ }
334
+ }
335
+
336
+ export type { MockOrder, MockProduct, MockDiscount };