@newmo/graphql-fake-server 0.19.1 → 0.20.1

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.
package/src/server.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs/promises";
2
2
  import http from "node:http";
3
+ import { isDeepStrictEqual } from "node:util";
3
4
  import { ApolloServer } from "@apollo/server";
4
5
  import { ApolloServerPluginDrainHttpServer } from "@apollo/server/plugin/drainHttpServer";
5
6
  import { expressMiddleware } from "@as-integrations/express5";
@@ -116,65 +117,302 @@ const creteApolloServer = async (options: FakeServerInternal) => {
116
117
  validationRules: [depthLimit(options.maxQueryDepth)],
117
118
  });
118
119
  };
120
+ // Allowed condition types
121
+ const ALLOWED_CONDITION_TYPES = ["count", "variables"] as const;
122
+ type AllowedConditionType = (typeof ALLOWED_CONDITION_TYPES)[number];
123
+
124
+ // Validation result type for better error messages
125
+ type ValidationResult<T> = { ok: true; data: T } | { ok: false; error: string };
126
+
127
+ // Condition rules for conditional fake responses
128
+ export type ConditionRule =
129
+ | {
130
+ type: "count";
131
+ value: number;
132
+ } // Match based on call count (nth call)
133
+ | {
134
+ type: "variables";
135
+ value: Record<string, unknown>;
136
+ }; // Match based on complete variables object
137
+
138
+ // Called result structure for tracking requests/responses
139
+ export type CalledResult = {
140
+ requestTimestamp: number;
141
+ request: {
142
+ headers: Record<string, string>;
143
+ body: Record<string, unknown>;
144
+ };
145
+ response: {
146
+ status: number;
147
+ headers: Record<string, string>;
148
+ body: unknown;
149
+ };
150
+ };
151
+
152
+ // Response type for the /called endpoint
153
+ export type CalledResultResponse = {
154
+ ok: boolean;
155
+ data: CalledResult[];
156
+ };
157
+
119
158
  export type RegisterSequenceNetworkError = {
120
159
  type: "network-error";
121
160
  operationName: string;
122
161
  responseStatusCode: number;
123
162
  errors: Record<string, unknown>[];
163
+ // Add request condition
164
+ requestCondition?: ConditionRule;
124
165
  };
125
166
  export type RegisterSequenceOperation = {
126
167
  type: "operation";
127
168
  operationName: string;
128
169
  data: Record<string, unknown>;
170
+ // Add request condition
171
+ requestCondition?: ConditionRule;
129
172
  };
130
173
  export type RegisterSequenceOptions = RegisterSequenceNetworkError | RegisterSequenceOperation;
131
- export type CalledResult = {
132
- requestTimestamp: number;
133
- request: {
134
- headers: Record<string, unknown>;
135
- body: Record<string, unknown>;
136
- };
137
- response: {
138
- status: number;
139
- headers: Record<string, unknown>;
140
- body: Record<string, unknown>;
174
+
175
+ /**
176
+ * Check if two condition types are conflicting and return specific error message
177
+ * Only the following combinations are allowed:
178
+ * - count + count
179
+ * - variables + variables
180
+ * - variables + no condition (undefined)
181
+ * - no condition (undefined) + no condition (undefined)
182
+ * All other combinations are conflicting
183
+ */
184
+ const areConditionTypesConflicting = (
185
+ conditionType1: ConditionRule["type"] | undefined,
186
+ conditionType2: ConditionRule["type"] | undefined,
187
+ ): { isConflicting: boolean; errorMessage?: string } => {
188
+ // Define allowed combinations with their descriptions
189
+ const allowedCombinations = new Map<string, string>([
190
+ // Multiple count conditions for the same operation (e.g., 1st call, 2nd call)
191
+ ["count,count", "Multiple count-based conditions are allowed for different call counts"],
192
+ // Multiple variables conditions for the same operation (e.g., different variable sets)
193
+ [
194
+ "variables,variables",
195
+ "Multiple variable-based conditions are allowed for different variable sets",
196
+ ],
197
+ // Variables condition can coexist with default fallback
198
+ ["variables,undefined", "Variable-based condition can coexist with default fallback"],
199
+ // Default fallback can coexist with variables condition
200
+ ["undefined,variables", "Default fallback can coexist with variable-based condition"],
201
+ // Multiple default conditions - overwrite with the last one
202
+ ["undefined,undefined", "Multiple default conditions are allowed (latest will be used)"],
203
+ ]);
204
+
205
+ const combinationKey = `${conditionType1 ?? "undefined"},${conditionType2 ?? "undefined"}`;
206
+
207
+ // If the combination is allowed, return no conflict
208
+ if (allowedCombinations.has(combinationKey)) {
209
+ return { isConflicting: false };
210
+ }
211
+
212
+ // Generate specific error message for conflicting combinations
213
+ const getTypeDescription = (type: ConditionRule["type"] | undefined): string => {
214
+ switch (type) {
215
+ case "count":
216
+ return "count-based condition (e.g., { type: 'count', value: 1 })";
217
+ case "variables":
218
+ return "variables-based condition (e.g., { type: 'variables', value: {...} })";
219
+ case undefined:
220
+ return "default condition (no requestCondition specified)";
221
+ default:
222
+ return `unknown condition type: ${type}`;
223
+ }
141
224
  };
225
+
226
+ const type1Desc = getTypeDescription(conditionType1);
227
+ const type2Desc = getTypeDescription(conditionType2);
228
+
229
+ // Specific error messages for common problematic combinations
230
+ if (
231
+ (conditionType1 === "count" && conditionType2 === "variables") ||
232
+ (conditionType1 === "variables" && conditionType2 === "count")
233
+ ) {
234
+ const errorMessage =
235
+ "Cannot mix count-based and variables-based conditions for the same operation. " +
236
+ "Use either multiple count conditions (for different call numbers) or multiple variables conditions (for different variable sets), " +
237
+ `but not both. Current conflict: ${type1Desc} vs ${type2Desc}`;
238
+ return { isConflicting: true, errorMessage };
239
+ }
240
+ const errorMessage =
241
+ `Conflicting condition types detected: ${type1Desc} vs ${type2Desc}. ` +
242
+ "Allowed combinations are: count+count, variables+variables, variables+default, or default+default.";
243
+ return { isConflicting: true, errorMessage };
142
244
  };
143
- export type CalledResultResponse = {
144
- ok: true;
145
- data: CalledResult[];
245
+
246
+ /**
247
+ * Get condition type from a RegisterSequenceOptions
248
+ */
249
+ const getConditionType = (fake: RegisterSequenceOptions): ConditionRule["type"] | undefined => {
250
+ return fake.requestCondition?.type;
146
251
  };
147
- export type RegisterOperationResponse =
148
- | {
149
- ok: true;
150
- }
151
- | {
152
- ok: false;
153
- errors: string[];
154
- };
155
- const validateSequenceRegistration = (data: unknown): data is RegisterSequenceOptions => {
156
- if (typeof data !== "object" || data === null) return false;
157
- if ("type" in data && typeof data.type === "string") {
158
- if (data.type === "network-error") {
159
- return (
160
- "errors" in data &&
161
- Array.isArray(data.errors) &&
162
- "responseStatusCode" in data &&
163
- typeof data.responseStatusCode === "number" &&
164
- "operationName" in data &&
165
- typeof data.operationName === "string"
166
- );
252
+
253
+ /**
254
+ * Check for condition conflicts in existing fakes for the same operation
255
+ */
256
+ const checkConditionConflicts = (
257
+ newFake: RegisterSequenceOptions,
258
+ existingConditionalFakes: RegisterSequenceOptions[],
259
+ existingDefaultFake: RegisterSequenceOptions | undefined,
260
+ ): string[] => {
261
+ const errors: string[] = [];
262
+ const newConditionType = getConditionType(newFake);
263
+
264
+ // Check conflicts with existing conditional fakes
265
+ for (const existingFake of existingConditionalFakes) {
266
+ const existingConditionType = getConditionType(existingFake);
267
+ const conflictResult = areConditionTypesConflicting(
268
+ newConditionType,
269
+ existingConditionType,
270
+ );
271
+ if (conflictResult.isConflicting && conflictResult.errorMessage) {
272
+ errors.push(conflictResult.errorMessage);
167
273
  }
168
- if (data.type === "operation") {
169
- return (
170
- "data" in data &&
171
- typeof data.data === "object" &&
172
- "operationName" in data &&
173
- typeof data.operationName === "string"
174
- );
274
+ }
275
+
276
+ // Check conflicts with existing default fake (no condition)
277
+ if (existingDefaultFake) {
278
+ const existingConditionType = getConditionType(existingDefaultFake);
279
+ const conflictResult = areConditionTypesConflicting(
280
+ newConditionType,
281
+ existingConditionType,
282
+ );
283
+ if (conflictResult.isConflicting && conflictResult.errorMessage) {
284
+ errors.push(conflictResult.errorMessage);
175
285
  }
176
286
  }
177
- return false;
287
+
288
+ return errors;
289
+ };
290
+
291
+ /**
292
+ * Validate condition rule structure
293
+ */
294
+ const validateConditionRule = (condition: unknown): ValidationResult<ConditionRule> => {
295
+ if (typeof condition !== "object" || condition === null) {
296
+ return { ok: false, error: "Condition must be an object" };
297
+ }
298
+
299
+ if (!("type" in condition) || typeof condition.type !== "string") {
300
+ return {
301
+ ok: false,
302
+ error: "Condition must have a 'type' field of type string",
303
+ };
304
+ }
305
+
306
+ // Check if type is in the allow list
307
+ if (!ALLOWED_CONDITION_TYPES.includes(condition.type as AllowedConditionType)) {
308
+ return {
309
+ ok: false,
310
+ error: `Unknown condition type '${
311
+ condition.type
312
+ }'. Allowed types: ${ALLOWED_CONDITION_TYPES.join(", ")}`,
313
+ };
314
+ }
315
+
316
+ if (!("value" in condition)) {
317
+ return { ok: false, error: "Condition must have a 'value' field" };
318
+ }
319
+
320
+ switch (condition.type) {
321
+ case "count":
322
+ if (typeof condition.value !== "number") {
323
+ return { ok: false, error: "Count condition value must be a number" };
324
+ }
325
+ if (condition.value <= 0) {
326
+ return {
327
+ ok: false,
328
+ error: "Count condition value must be greater than 0",
329
+ };
330
+ }
331
+ return { ok: true, data: condition as ConditionRule };
332
+
333
+ case "variables":
334
+ if (typeof condition.value !== "object" || condition.value === null) {
335
+ return {
336
+ ok: false,
337
+ error: "Variables condition value must be an object",
338
+ };
339
+ }
340
+ if (Array.isArray(condition.value)) {
341
+ return {
342
+ ok: false,
343
+ error: "Variables condition value must be an object, not an array",
344
+ };
345
+ }
346
+ return { ok: true, data: condition as ConditionRule };
347
+
348
+ default:
349
+ return {
350
+ ok: false,
351
+ error: `Unsupported condition type '${condition.type}'`,
352
+ };
353
+ }
354
+ };
355
+
356
+ const validateSequenceRegistration = (data: unknown): ValidationResult<RegisterSequenceOptions> => {
357
+ if (typeof data !== "object" || data === null) {
358
+ return { ok: false, error: "Request body must be an object" };
359
+ }
360
+
361
+ // Validate request condition
362
+ if ("requestCondition" in data && data.requestCondition !== undefined) {
363
+ const conditionResult = validateConditionRule(data.requestCondition);
364
+ if (!conditionResult.ok) {
365
+ return {
366
+ ok: false,
367
+ error: `Invalid request condition: ${conditionResult.error}`,
368
+ };
369
+ }
370
+ }
371
+
372
+ if (!("type" in data) || typeof data.type !== "string") {
373
+ return {
374
+ ok: false,
375
+ error: "Request body must have a 'type' field of type string",
376
+ };
377
+ }
378
+
379
+ if (!("operationName" in data) || typeof data.operationName !== "string") {
380
+ return {
381
+ ok: false,
382
+ error: "Request body must have an 'operationName' field of type string",
383
+ };
384
+ }
385
+
386
+ if (data.type === "network-error") {
387
+ if (!("errors" in data) || !Array.isArray(data.errors)) {
388
+ return {
389
+ ok: false,
390
+ error: "Network error type must have an 'errors' field of type array",
391
+ };
392
+ }
393
+ if (!("responseStatusCode" in data) || typeof data.responseStatusCode !== "number") {
394
+ return {
395
+ ok: false,
396
+ error: "Network error type must have a 'responseStatusCode' field of type number",
397
+ };
398
+ }
399
+ return { ok: true, data: data as RegisterSequenceOptions };
400
+ }
401
+
402
+ if (data.type === "operation") {
403
+ if (!("data" in data) || typeof data.data !== "object" || data.data === null) {
404
+ return {
405
+ ok: false,
406
+ error: "Operation type must have a 'data' field of type object",
407
+ };
408
+ }
409
+ return { ok: true, data: data as RegisterSequenceOptions };
410
+ }
411
+
412
+ return {
413
+ ok: false,
414
+ error: `Unknown request type '${data.type}'. Allowed types: 'operation', 'network-error'`,
415
+ };
178
416
  };
179
417
 
180
418
  class LRUMap<K, V> {
@@ -360,6 +598,14 @@ const createRoutingServer = async ({
360
598
  const sequenceFakeResponseLruMap = new LRUMap<string, RegisterSequenceOptions>({
361
599
  maxSize: maxRegisteredSequences,
362
600
  });
601
+ // Manage conditional fake responses (store multiple conditional responses)
602
+ const conditionalFakeResponseMap = new LRUMap<string, RegisterSequenceOptions[]>({
603
+ maxSize: maxRegisteredSequences,
604
+ });
605
+ // Track call count
606
+ const callCountMap = new LRUMap<string, number>({
607
+ maxSize: maxRegisteredSequences,
608
+ });
363
609
  // sequenceId x operationName -> Called Result
364
610
  // CalledResult is first request is index 0, second request is index 1 and so on
365
611
  const sequenceCalledResultLruMap = new LRUMap<string, CalledResult[]>({
@@ -373,10 +619,10 @@ const createRoutingServer = async ({
373
619
  const sequenceId = c.req.header("sequence-id");
374
620
  if (!sequenceId) {
375
621
  return Response.json(
376
- JSON.stringify({
622
+ {
377
623
  ok: false,
378
624
  errors: ["sequence-id is required"],
379
- }),
625
+ },
380
626
  {
381
627
  status: 400,
382
628
  },
@@ -387,50 +633,109 @@ const createRoutingServer = async ({
387
633
  sequenceId,
388
634
  body,
389
635
  });
390
- if (!validateSequenceRegistration(body)) {
391
- return Response.json(JSON.stringify({ ok: false, errors: ["invalid fake body"] }), {
392
- status: 400,
393
- });
636
+ const validationResult = validateSequenceRegistration(body);
637
+ if (!validationResult.ok) {
638
+ return Response.json(
639
+ { ok: false, errors: [validationResult.error] },
640
+ {
641
+ status: 400,
642
+ },
643
+ );
394
644
  }
395
- const operationName = body.operationName;
645
+ const operationName = validationResult.data.operationName;
396
646
  logger.debug("/fake got body type", {
397
647
  sequenceId,
398
- type: body.type,
648
+ type: validationResult.data.type,
649
+ requestCondition: validationResult.data.requestCondition,
399
650
  });
400
- sequenceFakeResponseLruMap.set(
401
- createMapKey({
402
- sequenceId,
403
- operationName,
404
- }),
405
- body,
406
- );
407
- return Response.json(JSON.stringify({ ok: true }), {
408
- status: 200,
651
+
652
+ const baseKey = createMapKey({
653
+ sequenceId,
654
+ operationName,
409
655
  });
656
+
657
+ // Check for condition conflicts before registration
658
+ const existingConditionalFakes = conditionalFakeResponseMap.get(baseKey) || [];
659
+ const existingDefaultFake = sequenceFakeResponseLruMap.get(baseKey);
660
+
661
+ const conflictErrors = checkConditionConflicts(
662
+ validationResult.data,
663
+ existingConditionalFakes,
664
+ existingDefaultFake,
665
+ );
666
+
667
+ if (conflictErrors.length > 0) {
668
+ return Response.json(
669
+ { ok: false, errors: conflictErrors },
670
+ {
671
+ status: 400,
672
+ },
673
+ );
674
+ }
675
+
676
+ // Register as conditional fake if request condition exists
677
+ if (validationResult.data.requestCondition) {
678
+ const existingConditionalFakes = conditionalFakeResponseMap.get(baseKey) || [];
679
+ // Overwrite if same condition exists, otherwise add new
680
+ const existingIndex = existingConditionalFakes.findIndex(
681
+ (fake) =>
682
+ fake.requestCondition &&
683
+ JSON.stringify(fake.requestCondition) ===
684
+ JSON.stringify(validationResult.data.requestCondition),
685
+ );
686
+
687
+ if (existingIndex >= 0) {
688
+ existingConditionalFakes[existingIndex] = validationResult.data;
689
+ } else {
690
+ existingConditionalFakes.push(validationResult.data);
691
+ }
692
+
693
+ // Sort by condition specificity (evaluate more specific conditions first)
694
+ existingConditionalFakes.sort((a, b) => {
695
+ const scoreA = a.requestCondition
696
+ ? calculateConditionSpecificity(a.requestCondition)
697
+ : 0;
698
+ const scoreB = b.requestCondition
699
+ ? calculateConditionSpecificity(b.requestCondition)
700
+ : 0;
701
+ return scoreB - scoreA; // Descending order
702
+ });
703
+
704
+ conditionalFakeResponseMap.set(baseKey, existingConditionalFakes);
705
+ } else {
706
+ // Without condition, use traditional approach
707
+ sequenceFakeResponseLruMap.set(baseKey, validationResult.data);
708
+ }
709
+ return Response.json(
710
+ { ok: true },
711
+ {
712
+ status: 200,
713
+ },
714
+ );
410
715
  });
411
716
  app.use("/fake/called", async (c) => {
412
- // sequenceId x operationName にマッチする CalledResult を返す
717
+ // Return CalledResult matching sequenceId x operationName
413
718
  const sequenceId = c.req.header("sequence-id");
414
719
  if (!sequenceId) {
415
720
  return Response.json(
416
- JSON.stringify({
721
+ {
417
722
  ok: false,
418
723
  errors: ["sequence-id is required"],
419
- }),
724
+ },
420
725
  {
421
726
  status: 400,
422
727
  },
423
728
  );
424
729
  }
425
- // req.bodyからoperationNameを取得
730
+ // Get operationName from req.body
426
731
  const body = await c.req.json();
427
732
  const operationName = body.operationName;
428
733
  if (!operationName) {
429
734
  return Response.json(
430
- JSON.stringify({
735
+ {
431
736
  ok: false,
432
737
  errors: ["operationName is required"],
433
- }),
738
+ },
434
739
  {
435
740
  status: 400,
436
741
  },
@@ -502,49 +807,102 @@ const createRoutingServer = async ({
502
807
  return passToApollo(c);
503
808
  }
504
809
 
505
- const sequence = sequenceFakeResponseLruMap.get(
506
- createMapKey({
507
- sequenceId,
508
- operationName: requestOperationName,
509
- }),
510
- );
810
+ const baseKey = createMapKey({
811
+ sequenceId,
812
+ operationName: requestOperationName,
813
+ });
814
+
815
+ // Increment call count
816
+ const currentCallCount = (callCountMap.get(baseKey) || 0) + 1;
817
+ callCountMap.set(baseKey, currentCallCount);
818
+
819
+ // Get request variables
820
+ const requestVariables =
821
+ typeof requestBody === "object" &&
822
+ requestBody !== null &&
823
+ "variables" in requestBody &&
824
+ typeof requestBody.variables === "object" &&
825
+ requestBody.variables !== null
826
+ ? (requestBody.variables as Record<string, unknown>)
827
+ : undefined;
828
+
829
+ // Check conditional fakes first
830
+ const conditionalFakes = conditionalFakeResponseMap.get(baseKey);
831
+ // Find the first matching conditional fake based on call count and variables
832
+ // If no conditional fake matches, use the default fake from sequenceFakeResponseLruMap
833
+ const matchedFake: RegisterSequenceOptions | undefined =
834
+ findMatchedConditionalFake({
835
+ conditionalFakes: conditionalFakes,
836
+ currentCallCount: currentCallCount,
837
+ requestVariables: requestVariables,
838
+ logger: logger,
839
+ sequenceId: sequenceId,
840
+ requestOperationName: requestOperationName,
841
+ }) ?? sequenceFakeResponseLruMap.get(baseKey);
842
+
511
843
  logger.debug(
512
- `fakeGraphQLQuery: sequence-id: ${sequenceId} x operationName: ${requestOperationName}, sequence exists: ${Boolean(
513
- sequence,
844
+ `fakeGraphQLQuery: sequence-id: ${sequenceId} x operationName: ${requestOperationName}, fake exists: ${Boolean(
845
+ matchedFake,
514
846
  )}`,
515
847
  {
516
- sequence,
848
+ matchedFake,
517
849
  sequenceId,
518
850
  operationName: requestOperationName,
851
+ callCount: currentCallCount,
519
852
  },
520
853
  );
521
- if (!sequence) {
522
- logger.debug("fakeGraphQLQuery: no sequence found, passing to Apollo");
854
+
855
+ if (!matchedFake) {
856
+ logger.debug("fakeGraphQLQuery: no fake found, passing to Apollo");
523
857
  return passToApollo(c);
524
858
  }
525
859
 
526
- if (requestOperationName !== sequence.operationName) {
860
+ if (requestOperationName !== matchedFake.operationName) {
527
861
  logger.debug("fakeGraphQLQuery: operationName mismatch, returning error");
528
862
  return Response.json(
529
- JSON.stringify({
863
+ {
530
864
  errors: [
531
865
  `operationName does not match. operationName: ${requestOperationName} sequenceId: ${sequenceId}`,
532
866
  ],
533
- }),
867
+ },
534
868
  {
535
869
  status: 400,
536
870
  },
537
871
  );
538
872
  }
539
873
 
540
- if (sequence.type === "network-error") {
874
+ if (matchedFake.type === "network-error") {
541
875
  logger.debug("fakeGraphQLQuery: network-error type, returning error");
876
+
877
+ // Record call history for error responses as well
878
+ const cacheKey = createMapKey({
879
+ sequenceId,
880
+ operationName: requestOperationName,
881
+ });
882
+ sequenceCalledResultLruMap.set(cacheKey, [
883
+ ...(sequenceCalledResultLruMap.get(cacheKey) ?? []),
884
+ {
885
+ requestTimestamp: Date.now(),
886
+ request: {
887
+ headers: Object.fromEntries(c.req.raw.headers),
888
+ body: requestBody as Record<string, unknown>,
889
+ },
890
+ response: {
891
+ status: matchedFake.responseStatusCode,
892
+ headers: { "Content-Type": "application/json" },
893
+ body: {
894
+ errors: matchedFake.errors,
895
+ },
896
+ },
897
+ },
898
+ ]);
899
+
542
900
  return new Response(
543
901
  JSON.stringify({
544
- errors: sequence.errors,
902
+ errors: matchedFake.errors,
545
903
  }),
546
904
  {
547
- status: sequence.responseStatusCode,
905
+ status: matchedFake.responseStatusCode,
548
906
  },
549
907
  );
550
908
  }
@@ -577,13 +935,13 @@ const createRoutingServer = async ({
577
935
  });
578
936
 
579
937
  // 5. Merge the registration data with the response
580
- const data = sequence.data;
938
+ const data = matchedFake.data;
581
939
  logger.debug(`fakeGraphQLQuery: starting data merge sequence-id: ${sequenceId}`, {
582
940
  data,
583
941
  responseBody,
584
942
  });
585
943
  // Use bracket notation for properties from index signature
586
- const responseData = responseBody["data"] as any;
944
+ const responseData = responseBody["data"] as unknown;
587
945
  const merged = {
588
946
  ...(typeof responseData === "object" && responseData !== null ? responseData : {}),
589
947
  ...data,
@@ -612,13 +970,12 @@ const createRoutingServer = async ({
612
970
  ]);
613
971
 
614
972
  logger.debug("fakeGraphQLQuery: merge completed, returning response");
615
- // "content-length" should be matched from the response body length
973
+ // Let the server automatically calculate Content-Length to avoid issues with multi-byte characters
616
974
  const responseJson = JSON.stringify({ data: merged });
617
975
  return new Response(responseJson, {
618
976
  status: proxyResponse.status,
619
977
  headers: {
620
978
  "Content-Type": "application/json",
621
- "Content-Length": responseJson.length.toString(),
622
979
  },
623
980
  });
624
981
  };
@@ -730,3 +1087,85 @@ export const createFakeServerInternal = async (options: FakeServerInternal) => {
730
1087
  },
731
1088
  };
732
1089
  };
1090
+
1091
+ /**
1092
+ * Check if condition rule matches the current request context
1093
+ */
1094
+ const evaluateCondition = (
1095
+ condition: ConditionRule,
1096
+ context: {
1097
+ callCount: number;
1098
+ variables?: Record<string, unknown>;
1099
+ },
1100
+ ): boolean => {
1101
+ switch (condition.type) {
1102
+ case "count":
1103
+ return context.callCount === condition.value;
1104
+
1105
+ case "variables":
1106
+ if (!context.variables) return false;
1107
+ return isDeepStrictEqual(context.variables, condition.value);
1108
+
1109
+ default:
1110
+ return false;
1111
+ }
1112
+ };
1113
+
1114
+ /**
1115
+ * Calculate condition specificity score (used for matching priority)
1116
+ */
1117
+ const calculateConditionSpecificity = (condition: ConditionRule): number => {
1118
+ switch (condition.type) {
1119
+ case "count":
1120
+ return 10; // count conditions have medium priority
1121
+
1122
+ case "variables":
1123
+ return 20; // variables conditions have high priority
1124
+
1125
+ default:
1126
+ return 0;
1127
+ }
1128
+ };
1129
+
1130
+ /**
1131
+ * Find a matching conditional fake based on the current call count and request variables
1132
+ */
1133
+ const findMatchedConditionalFake = ({
1134
+ conditionalFakes,
1135
+ currentCallCount,
1136
+ requestVariables,
1137
+ logger,
1138
+ sequenceId,
1139
+ requestOperationName,
1140
+ }: {
1141
+ conditionalFakes: RegisterSequenceOptions[] | undefined;
1142
+ currentCallCount: number;
1143
+ requestVariables: Record<string, unknown> | undefined;
1144
+ logger: ReturnType<typeof createLogger>;
1145
+ sequenceId: string;
1146
+ requestOperationName: string;
1147
+ }): RegisterSequenceOptions | undefined => {
1148
+ if (conditionalFakes && conditionalFakes.length > 0) {
1149
+ // Find matching fake (already sorted by specificity in descending order)
1150
+ for (const fake of conditionalFakes) {
1151
+ if (fake.requestCondition) {
1152
+ const context = {
1153
+ callCount: currentCallCount,
1154
+ ...(requestVariables && { variables: requestVariables }),
1155
+ };
1156
+
1157
+ if (evaluateCondition(fake.requestCondition, context)) {
1158
+ logger.debug("fakeGraphQLQuery: matched conditional fake", {
1159
+ sequenceId,
1160
+ operationName: requestOperationName,
1161
+ requestCondition: fake.requestCondition,
1162
+ callCount: currentCallCount,
1163
+ variables: requestVariables,
1164
+ });
1165
+ return fake;
1166
+ }
1167
+ }
1168
+ }
1169
+ }
1170
+ return undefined;
1171
+ };