@pagopa/dx-savemoney 0.3.2 → 0.4.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.
Files changed (163) hide show
  1. package/README.md +12 -0
  2. package/dist/__tests__/finding.test.js +22 -0
  3. package/dist/__tests__/finding.test.js.map +1 -1
  4. package/dist/azure/__tests__/analyzer-tags.test.js +28 -1
  5. package/dist/azure/__tests__/analyzer-tags.test.js.map +1 -1
  6. package/dist/azure/__tests__/config.test.js +10 -0
  7. package/dist/azure/__tests__/config.test.js.map +1 -1
  8. package/dist/azure/__tests__/report.test.js +71 -0
  9. package/dist/azure/__tests__/report.test.js.map +1 -1
  10. package/dist/azure/analyzer.d.ts +8 -1
  11. package/dist/azure/analyzer.d.ts.map +1 -1
  12. package/dist/azure/analyzer.js +55 -37
  13. package/dist/azure/analyzer.js.map +1 -1
  14. package/dist/azure/analyzers/registry.d.ts.map +1 -1
  15. package/dist/azure/analyzers/registry.js +4 -4
  16. package/dist/azure/analyzers/registry.js.map +1 -1
  17. package/dist/azure/analyzers/types.d.ts +6 -0
  18. package/dist/azure/analyzers/types.d.ts.map +1 -1
  19. package/dist/azure/pricing/__tests__/cache.test.d.ts +8 -0
  20. package/dist/azure/pricing/__tests__/cache.test.d.ts.map +1 -0
  21. package/dist/azure/pricing/__tests__/cache.test.js +56 -0
  22. package/dist/azure/pricing/__tests__/cache.test.js.map +1 -0
  23. package/dist/azure/pricing/__tests__/client.test.d.ts +9 -0
  24. package/dist/azure/pricing/__tests__/client.test.d.ts.map +1 -0
  25. package/dist/azure/pricing/__tests__/client.test.js +181 -0
  26. package/dist/azure/pricing/__tests__/client.test.js.map +1 -0
  27. package/dist/azure/pricing/__tests__/pricing-service.test.d.ts +6 -0
  28. package/dist/azure/pricing/__tests__/pricing-service.test.d.ts.map +1 -0
  29. package/dist/azure/pricing/__tests__/pricing-service.test.js +283 -0
  30. package/dist/azure/pricing/__tests__/pricing-service.test.js.map +1 -0
  31. package/dist/azure/pricing/cache.d.ts +45 -0
  32. package/dist/azure/pricing/cache.d.ts.map +1 -0
  33. package/dist/azure/pricing/cache.js +79 -0
  34. package/dist/azure/pricing/cache.js.map +1 -0
  35. package/dist/azure/pricing/client.d.ts +67 -0
  36. package/dist/azure/pricing/client.d.ts.map +1 -0
  37. package/dist/azure/pricing/client.js +96 -0
  38. package/dist/azure/pricing/client.js.map +1 -0
  39. package/dist/azure/pricing/index.d.ts +6 -0
  40. package/dist/azure/pricing/index.d.ts.map +1 -0
  41. package/dist/azure/pricing/index.js +6 -0
  42. package/dist/azure/pricing/index.js.map +1 -0
  43. package/dist/azure/pricing/pricing-service.d.ts +46 -0
  44. package/dist/azure/pricing/pricing-service.d.ts.map +1 -0
  45. package/dist/azure/pricing/pricing-service.js +84 -0
  46. package/dist/azure/pricing/pricing-service.js.map +1 -0
  47. package/dist/azure/pricing/resolvers/__tests__/app-service.test.d.ts +5 -0
  48. package/dist/azure/pricing/resolvers/__tests__/app-service.test.d.ts.map +1 -0
  49. package/dist/azure/pricing/resolvers/__tests__/app-service.test.js +137 -0
  50. package/dist/azure/pricing/resolvers/__tests__/app-service.test.js.map +1 -0
  51. package/dist/azure/pricing/resolvers/__tests__/disk.test.d.ts +5 -0
  52. package/dist/azure/pricing/resolvers/__tests__/disk.test.d.ts.map +1 -0
  53. package/dist/azure/pricing/resolvers/__tests__/disk.test.js +160 -0
  54. package/dist/azure/pricing/resolvers/__tests__/disk.test.js.map +1 -0
  55. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.d.ts +5 -0
  56. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.d.ts.map +1 -0
  57. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.js +113 -0
  58. package/dist/azure/pricing/resolvers/__tests__/public-ip.test.js.map +1 -0
  59. package/dist/azure/pricing/resolvers/__tests__/vm.test.d.ts +9 -0
  60. package/dist/azure/pricing/resolvers/__tests__/vm.test.d.ts.map +1 -0
  61. package/dist/azure/pricing/resolvers/__tests__/vm.test.js +204 -0
  62. package/dist/azure/pricing/resolvers/__tests__/vm.test.js.map +1 -0
  63. package/dist/azure/pricing/resolvers/app-service.d.ts +27 -0
  64. package/dist/azure/pricing/resolvers/app-service.d.ts.map +1 -0
  65. package/dist/azure/pricing/resolvers/app-service.js +67 -0
  66. package/dist/azure/pricing/resolvers/app-service.js.map +1 -0
  67. package/dist/azure/pricing/resolvers/disk.d.ts +59 -0
  68. package/dist/azure/pricing/resolvers/disk.d.ts.map +1 -0
  69. package/dist/azure/pricing/resolvers/disk.js +140 -0
  70. package/dist/azure/pricing/resolvers/disk.js.map +1 -0
  71. package/dist/azure/pricing/resolvers/index.d.ts +12 -0
  72. package/dist/azure/pricing/resolvers/index.d.ts.map +1 -0
  73. package/dist/azure/pricing/resolvers/index.js +8 -0
  74. package/dist/azure/pricing/resolvers/index.js.map +1 -0
  75. package/dist/azure/pricing/resolvers/public-ip.d.ts +32 -0
  76. package/dist/azure/pricing/resolvers/public-ip.d.ts.map +1 -0
  77. package/dist/azure/pricing/resolvers/public-ip.js +56 -0
  78. package/dist/azure/pricing/resolvers/public-ip.js.map +1 -0
  79. package/dist/azure/pricing/resolvers/vm.d.ts +51 -0
  80. package/dist/azure/pricing/resolvers/vm.d.ts.map +1 -0
  81. package/dist/azure/pricing/resolvers/vm.js +89 -0
  82. package/dist/azure/pricing/resolvers/vm.js.map +1 -0
  83. package/dist/azure/pricing/schema.d.ts +88 -0
  84. package/dist/azure/pricing/schema.d.ts.map +1 -0
  85. package/dist/azure/pricing/schema.js +64 -0
  86. package/dist/azure/pricing/schema.js.map +1 -0
  87. package/dist/azure/pricing/test-cache-dir.d.ts +3 -0
  88. package/dist/azure/pricing/test-cache-dir.d.ts.map +1 -0
  89. package/dist/azure/pricing/test-cache-dir.js +17 -0
  90. package/dist/azure/pricing/test-cache-dir.js.map +1 -0
  91. package/dist/azure/report.d.ts.map +1 -1
  92. package/dist/azure/report.js +57 -18
  93. package/dist/azure/report.js.map +1 -1
  94. package/dist/azure/resources/__tests__/app-service.test.d.ts +5 -0
  95. package/dist/azure/resources/__tests__/app-service.test.d.ts.map +1 -0
  96. package/dist/azure/resources/__tests__/app-service.test.js +105 -0
  97. package/dist/azure/resources/__tests__/app-service.test.js.map +1 -0
  98. package/dist/azure/resources/app-service.d.ts +8 -3
  99. package/dist/azure/resources/app-service.d.ts.map +1 -1
  100. package/dist/azure/resources/app-service.js +35 -2
  101. package/dist/azure/resources/app-service.js.map +1 -1
  102. package/dist/azure/resources/disk.d.ts +3 -1
  103. package/dist/azure/resources/disk.d.ts.map +1 -1
  104. package/dist/azure/resources/disk.js +35 -3
  105. package/dist/azure/resources/disk.js.map +1 -1
  106. package/dist/azure/resources/public-ip.d.ts +3 -1
  107. package/dist/azure/resources/public-ip.d.ts.map +1 -1
  108. package/dist/azure/resources/public-ip.js +41 -3
  109. package/dist/azure/resources/public-ip.js.map +1 -1
  110. package/dist/azure/resources/vm.d.ts +4 -1
  111. package/dist/azure/resources/vm.d.ts.map +1 -1
  112. package/dist/azure/resources/vm.js +49 -6
  113. package/dist/azure/resources/vm.js.map +1 -1
  114. package/dist/azure/types.d.ts +13 -0
  115. package/dist/azure/types.d.ts.map +1 -1
  116. package/dist/finding.d.ts +3 -3
  117. package/dist/finding.d.ts.map +1 -1
  118. package/dist/finding.js +7 -0
  119. package/dist/finding.js.map +1 -1
  120. package/dist/index.d.ts +2 -2
  121. package/dist/index.d.ts.map +1 -1
  122. package/dist/index.js.map +1 -1
  123. package/dist/types.d.ts +8 -0
  124. package/dist/types.d.ts.map +1 -1
  125. package/dist/types.js +2 -0
  126. package/dist/types.js.map +1 -1
  127. package/package.json +9 -9
  128. package/src/__tests__/finding.test.ts +24 -0
  129. package/src/azure/__tests__/analyzer-tags.test.ts +36 -1
  130. package/src/azure/__tests__/config.test.ts +12 -0
  131. package/src/azure/__tests__/report.test.ts +78 -0
  132. package/src/azure/analyzer.ts +67 -43
  133. package/src/azure/analyzers/registry.ts +8 -2
  134. package/src/azure/analyzers/types.ts +6 -0
  135. package/src/azure/pricing/__tests__/cache.test.ts +67 -0
  136. package/src/azure/pricing/__tests__/client.test.ts +212 -0
  137. package/src/azure/pricing/__tests__/fixtures/vm-b2s-westeurope.json +51 -0
  138. package/src/azure/pricing/__tests__/pricing-service.test.ts +315 -0
  139. package/src/azure/pricing/cache.ts +92 -0
  140. package/src/azure/pricing/client.ts +137 -0
  141. package/src/azure/pricing/index.ts +6 -0
  142. package/src/azure/pricing/pricing-service.ts +125 -0
  143. package/src/azure/pricing/resolvers/__tests__/app-service.test.ts +154 -0
  144. package/src/azure/pricing/resolvers/__tests__/disk.test.ts +189 -0
  145. package/src/azure/pricing/resolvers/__tests__/public-ip.test.ts +130 -0
  146. package/src/azure/pricing/resolvers/__tests__/vm.test.ts +232 -0
  147. package/src/azure/pricing/resolvers/app-service.ts +102 -0
  148. package/src/azure/pricing/resolvers/disk.ts +170 -0
  149. package/src/azure/pricing/resolvers/index.ts +31 -0
  150. package/src/azure/pricing/resolvers/public-ip.ts +87 -0
  151. package/src/azure/pricing/resolvers/vm.ts +125 -0
  152. package/src/azure/pricing/schema.ts +69 -0
  153. package/src/azure/pricing/test-cache-dir.ts +26 -0
  154. package/src/azure/report.ts +86 -21
  155. package/src/azure/resources/__tests__/app-service.test.ts +165 -0
  156. package/src/azure/resources/app-service.ts +65 -4
  157. package/src/azure/resources/disk.ts +46 -4
  158. package/src/azure/resources/public-ip.ts +61 -2
  159. package/src/azure/resources/vm.ts +89 -11
  160. package/src/azure/types.ts +14 -0
  161. package/src/finding.ts +10 -3
  162. package/src/index.ts +3 -3
  163. package/src/types.ts +12 -0
@@ -54,6 +54,7 @@ import {
54
54
  createDefaultSubscriptionAnalyzers,
55
55
  type SubscriptionAnalyzer,
56
56
  } from "./analyzers/index.js";
57
+ import { PricingClient, PricingService } from "./pricing/index.js";
57
58
  import { matchesTags, type MetricsCache } from "./utils.js";
58
59
 
59
60
  const DEFAULT_CONCURRENCY = 8;
@@ -95,6 +96,10 @@ export async function analyzeAzureResources(
95
96
  : [];
96
97
  const thresholds: Thresholds = config.thresholds ?? DEFAULT_THRESHOLDS;
97
98
 
99
+ // Build a single PricingService for the whole run when enabled, so the
100
+ // disk cache and in-memory memo are shared across subscriptions.
101
+ const pricing = buildPricingService(config);
102
+
98
103
  // Normalise concurrency the same way p-limit does to keep maxInFlight
99
104
  // consistent. A raw value of 0/NaN would produce maxInFlight = 0/NaN and
100
105
  // either deadlock or silently disable backpressure.
@@ -140,6 +145,7 @@ export async function analyzeAzureResources(
140
145
  limit,
141
146
  logger,
142
147
  maxInFlight,
148
+ pricing,
143
149
  reports: allReports,
144
150
  reportsById,
145
151
  runCache,
@@ -214,6 +220,7 @@ export async function analyzeResource(
214
220
  timespanDays: number,
215
221
  thresholds: Thresholds,
216
222
  verbose = false,
223
+ pricing?: PricingService,
217
224
  ): Promise<AnalysisResult> {
218
225
  let result: AnalysisResult = {
219
226
  costRisk: "low",
@@ -231,6 +238,7 @@ export async function analyzeResource(
231
238
  clients,
232
239
  metricsCache,
233
240
  preferredLocation,
241
+ pricing,
234
242
  resource,
235
243
  thresholds,
236
244
  timespanDays,
@@ -262,6 +270,50 @@ export async function analyzeResource(
262
270
  return { ...result, reason: result.reason.trim() };
263
271
  }
264
272
 
273
+ /**
274
+ * Inserts a `Finding` into the right report entry, creating a stub
275
+ * resource entry on the fly when the finding refers to a resource that
276
+ * the per-resource pass did not analyze.
277
+ */
278
+ export function mergeFinding(
279
+ finding: Finding,
280
+ reports: AzureDetailedResourceReport[],
281
+ reportsById: Map<string, AzureDetailedResourceReport>,
282
+ ): void {
283
+ const idKey = finding.resourceId.toLowerCase();
284
+ const existing = reportsById.get(idKey);
285
+ if (existing) {
286
+ existing.findings = [...(existing.findings ?? []), finding];
287
+ const added = analysisFromFinding(finding);
288
+ // Use max costRisk (not last-wins) and join reasons with a space so we
289
+ // don't produce "Sentence one.Sentence two." when the existing reason is
290
+ // already trimmed (i.e. has no trailing separator space).
291
+ existing.analysis = {
292
+ costRisk:
293
+ RISK_ORDER[existing.analysis.costRisk] <= RISK_ORDER[added.costRisk]
294
+ ? existing.analysis.costRisk
295
+ : added.costRisk,
296
+ estimatedMonthlySavings: existing.analysis.estimatedMonthlySavings,
297
+ reason:
298
+ existing.analysis.reason && added.reason
299
+ ? `${existing.analysis.reason.trimEnd()} ${added.reason.trimStart()}`
300
+ : existing.analysis.reason || added.reason,
301
+ suspectedUnused:
302
+ existing.analysis.suspectedUnused || added.suspectedUnused,
303
+ };
304
+ return;
305
+ }
306
+
307
+ const stub = buildResourceStub(finding.resourceId);
308
+ const report: AzureDetailedResourceReport = {
309
+ analysis: analysisFromFinding(finding),
310
+ findings: [finding],
311
+ resource: stub,
312
+ };
313
+ reports.push(report);
314
+ reportsById.set(idKey, report);
315
+ }
316
+
265
317
  export function shouldIncludeAdvisorFindingForTags(
266
318
  finding: Finding,
267
319
  taggedResourceIds: ReadonlySet<string>,
@@ -295,6 +347,18 @@ function analysisFromFinding(finding: Finding): AnalysisResult {
295
347
  };
296
348
  }
297
349
 
350
+ /**
351
+ * Builds the shared `PricingService` for the run when pricing enrichment
352
+ * is enabled. Returns `undefined` when the CLI disables pricing via
353
+ * `--no-pricing`; otherwise the client/cache use internal defaults.
354
+ */
355
+ function buildPricingService(config: AzureConfig): PricingService | undefined {
356
+ if (config.pricing?.enabled === false) {
357
+ return undefined;
358
+ }
359
+ return new PricingService(new PricingClient());
360
+ }
361
+
298
362
  /**
299
363
  * Builds a minimal `GenericResource` from a resource ID. Used when a
300
364
  * subscription-level analyzer surfaces a resource the per-resource pass
@@ -383,49 +447,6 @@ function isSubscriptionScopedReport(r: AzureDetailedResourceReport): boolean {
383
447
  return r.resource.type === "Microsoft.Subscription";
384
448
  }
385
449
 
386
- /**
387
- * Inserts a `Finding` into the right report entry, creating a stub
388
- * resource entry on the fly when the finding refers to a resource that
389
- * the per-resource pass did not analyze.
390
- */
391
- function mergeFinding(
392
- finding: Finding,
393
- reports: AzureDetailedResourceReport[],
394
- reportsById: Map<string, AzureDetailedResourceReport>,
395
- ): void {
396
- const idKey = finding.resourceId.toLowerCase();
397
- const existing = reportsById.get(idKey);
398
- if (existing) {
399
- existing.findings = [...(existing.findings ?? []), finding];
400
- const added = analysisFromFinding(finding);
401
- // Use max costRisk (not last-wins) and join reasons with a space so we
402
- // don't produce "Sentence one.Sentence two." when the existing reason is
403
- // already trimmed (i.e. has no trailing separator space).
404
- existing.analysis = {
405
- costRisk:
406
- RISK_ORDER[existing.analysis.costRisk] <= RISK_ORDER[added.costRisk]
407
- ? existing.analysis.costRisk
408
- : added.costRisk,
409
- reason:
410
- existing.analysis.reason && added.reason
411
- ? `${existing.analysis.reason.trimEnd()} ${added.reason.trimStart()}`
412
- : existing.analysis.reason || added.reason,
413
- suspectedUnused:
414
- existing.analysis.suspectedUnused || added.suspectedUnused,
415
- };
416
- return;
417
- }
418
-
419
- const stub = buildResourceStub(finding.resourceId);
420
- const report: AzureDetailedResourceReport = {
421
- analysis: analysisFromFinding(finding),
422
- findings: [finding],
423
- resource: stub,
424
- };
425
- reports.push(report);
426
- reportsById.set(idKey, report);
427
- }
428
-
429
450
  function normalizeResourceId(resourceId: string | undefined): string {
430
451
  return (resourceId ?? "").trim().toLowerCase();
431
452
  }
@@ -443,6 +464,7 @@ async function runPerResourceAnalysis(args: {
443
464
  limit: ReturnType<typeof pLimit>;
444
465
  logger: ReturnType<typeof getLogger>;
445
466
  maxInFlight: number;
467
+ pricing?: PricingService;
446
468
  reports: AzureDetailedResourceReport[];
447
469
  reportsById: Map<string, AzureDetailedResourceReport>;
448
470
  runCache: MetricsCache;
@@ -458,6 +480,7 @@ async function runPerResourceAnalysis(args: {
458
480
  limit,
459
481
  logger,
460
482
  maxInFlight,
483
+ pricing,
461
484
  reports,
462
485
  reportsById,
463
486
  runCache,
@@ -500,6 +523,7 @@ async function runPerResourceAnalysis(args: {
500
523
  config.timespanDays,
501
524
  thresholds,
502
525
  config.verbose || false,
526
+ pricing,
503
527
  );
504
528
 
505
529
  if (analysis.suspectedUnused) {
@@ -57,8 +57,8 @@ export function createDefaultAnalyzers(): Analyzer[] {
57
57
  supports: (r) => r.type?.toLowerCase() === "microsoft.app/containerapps",
58
58
  },
59
59
  {
60
- analyze: ({ clients, resource, verbose }) =>
61
- analyzeDisk(resource, clients.compute, verbose),
60
+ analyze: ({ clients, pricing, resource, verbose }) =>
61
+ analyzeDisk(resource, clients.compute, verbose, pricing),
62
62
  id: "azure.disk",
63
63
  supports: (r) => r.type?.toLowerCase() === "microsoft.compute/disks",
64
64
  },
@@ -66,6 +66,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
66
66
  analyze: ({
67
67
  clients,
68
68
  metricsCache,
69
+ pricing,
69
70
  resource,
70
71
  thresholds,
71
72
  timespanDays,
@@ -79,6 +80,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
79
80
  thresholds,
80
81
  verbose,
81
82
  metricsCache,
83
+ pricing,
82
84
  ),
83
85
  id: "azure.vm",
84
86
  supports: (r) =>
@@ -102,6 +104,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
102
104
  analyze: ({
103
105
  clients,
104
106
  metricsCache,
107
+ pricing,
105
108
  resource,
106
109
  thresholds,
107
110
  timespanDays,
@@ -115,6 +118,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
115
118
  thresholds,
116
119
  verbose,
117
120
  metricsCache,
121
+ pricing,
118
122
  ),
119
123
  id: "azure.public-ip",
120
124
  supports: (r) =>
@@ -145,6 +149,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
145
149
  analyze: ({
146
150
  clients,
147
151
  metricsCache,
152
+ pricing,
148
153
  resource,
149
154
  thresholds,
150
155
  timespanDays,
@@ -158,6 +163,7 @@ export function createDefaultAnalyzers(): Analyzer[] {
158
163
  thresholds,
159
164
  verbose,
160
165
  metricsCache,
166
+ pricing,
161
167
  ),
162
168
  id: "azure.app-service-plan",
163
169
  supports: (r) => r.type?.toLowerCase() === "microsoft.web/serverfarms",
@@ -20,6 +20,7 @@ import type { NetworkManagementClient } from "@azure/arm-network";
20
20
  import type * as armResources from "@azure/arm-resources";
21
21
 
22
22
  import type { AnalysisResult, Thresholds } from "../../types.js";
23
+ import type { PricingService } from "../pricing/pricing-service.js";
23
24
  import type { MetricsCache } from "../utils.js";
24
25
 
25
26
  /**
@@ -46,6 +47,11 @@ export type AnalyzerContext = {
46
47
  */
47
48
  metricsCache: MetricsCache;
48
49
  preferredLocation: string;
50
+ /**
51
+ * Optional pricing facade used to enrich findings with an estimated
52
+ * monthly cost. Absent when pricing is disabled via config / CLI.
53
+ */
54
+ pricing?: PricingService;
49
55
  resource: armResources.GenericResource;
50
56
  thresholds: Thresholds;
51
57
  timespanDays: number;
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Tests for the disk-backed pricing cache.
3
+ *
4
+ * Each test uses a freshly-created temp directory so they do not leak
5
+ * state into the developer's real pricing cache.
6
+ */
7
+
8
+ import { writeFile } from "node:fs/promises";
9
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
10
+
11
+ import { DiskCache } from "../cache.js";
12
+ import { makeTestCacheDir, removeTestCacheDir } from "../test-cache-dir.js";
13
+
14
+ describe("DiskCache", () => {
15
+ let dir: string;
16
+ beforeEach(async () => {
17
+ dir = await makeTestCacheDir("cache");
18
+ });
19
+ afterEach(async () => {
20
+ await removeTestCacheDir(dir);
21
+ });
22
+
23
+ it("returns undefined on a miss", async () => {
24
+ const cache = new DiskCache({ dir });
25
+ await expect(cache.get("nope")).resolves.toBeUndefined();
26
+ });
27
+
28
+ it("returns the value written under the same key", async () => {
29
+ const cache = new DiskCache({ dir });
30
+ await cache.set("hello", { hits: 42 });
31
+ await expect(cache.get("hello")).resolves.toEqual({ hits: 42 });
32
+ });
33
+
34
+ it("treats different keys as independent entries", async () => {
35
+ const cache = new DiskCache({ dir });
36
+ await cache.set("a", 1);
37
+ await cache.set("b", 2);
38
+ await expect(cache.get("a")).resolves.toBe(1);
39
+ await expect(cache.get("b")).resolves.toBe(2);
40
+ });
41
+
42
+ it("returns undefined once the entry is older than ttlMs", async () => {
43
+ const cache = new DiskCache({ dir, ttlMs: 1 });
44
+ await cache.set("stale", { v: 1 });
45
+ // Wait long enough to outlast the 1 ms TTL.
46
+ await new Promise((resolve) => setTimeout(resolve, 25));
47
+ await expect(cache.get("stale")).resolves.toBeUndefined();
48
+ });
49
+
50
+ it("returns undefined when the cache file contains invalid JSON", async () => {
51
+ const cache = new DiskCache({ dir });
52
+ const path = cache.pathFor("broken");
53
+ await writeFile(path, "{not json", "utf8");
54
+ await expect(cache.get("broken")).resolves.toBeUndefined();
55
+ });
56
+
57
+ it("derives different paths for different keys", () => {
58
+ const cache = new DiskCache({ dir });
59
+ expect(cache.pathFor("a")).not.toBe(cache.pathFor("b"));
60
+ });
61
+
62
+ it("refuses to remove directories outside the test cache parent", async () => {
63
+ await expect(removeTestCacheDir(process.cwd())).rejects.toThrow(
64
+ /non-test cache directory/,
65
+ );
66
+ });
67
+ });
@@ -0,0 +1,212 @@
1
+ /**
2
+ * Tests for the Azure Retail Prices HTTP client.
3
+ *
4
+ * The client is exercised against an in-process fake `fetch` so the suite
5
+ * never reaches the real `prices.azure.com` endpoint. A temp directory is
6
+ * used as cache so each test starts from a cold state.
7
+ */
8
+
9
+ import { readFile } from "node:fs/promises";
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
11
+
12
+ import type { FetchLike } from "../client.js";
13
+
14
+ import { DiskCache } from "../cache.js";
15
+ import { PricingClient } from "../client.js";
16
+ import { makeTestCacheDir, removeTestCacheDir } from "../test-cache-dir.js";
17
+
18
+ const FIXTURE_PATH = new URL(
19
+ "./fixtures/vm-b2s-westeurope.json",
20
+ import.meta.url,
21
+ );
22
+
23
+ async function loadFixture(): Promise<unknown> {
24
+ const raw = await readFile(FIXTURE_PATH, "utf8");
25
+ return JSON.parse(raw);
26
+ }
27
+
28
+ function makeOkFetch(body: unknown): FetchLike {
29
+ return vi.fn(async () => ({
30
+ json: async () => body,
31
+ ok: true,
32
+ status: 200,
33
+ statusText: "OK",
34
+ }));
35
+ }
36
+
37
+ describe("PricingClient.query", () => {
38
+ let dir: string;
39
+ beforeEach(async () => {
40
+ dir = await makeTestCacheDir("client");
41
+ });
42
+ afterEach(async () => {
43
+ await removeTestCacheDir(dir);
44
+ });
45
+
46
+ it("returns the items from a single page response", async () => {
47
+ const body = await loadFixture();
48
+ const fetchImpl = makeOkFetch(body);
49
+ const client = new PricingClient({
50
+ cache: new DiskCache({ dir }),
51
+ fetch: fetchImpl,
52
+ });
53
+
54
+ const items = await client.query("serviceName eq 'Virtual Machines'");
55
+
56
+ expect(items).toHaveLength(2);
57
+ expect(items[0].armSkuName).toBe("Standard_B2s");
58
+ expect(items[0].unitPrice).toBe(0.057);
59
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
60
+ });
61
+
62
+ it("includes the filter, currency, and api-version in the request URL", async () => {
63
+ const body = await loadFixture();
64
+ const fetchImpl = makeOkFetch(body);
65
+ const client = new PricingClient({
66
+ cache: new DiskCache({ dir }),
67
+ currencyCode: "EUR",
68
+ fetch: fetchImpl,
69
+ });
70
+
71
+ await client.query("serviceName eq 'Virtual Machines'");
72
+
73
+ const url = (fetchImpl as ReturnType<typeof vi.fn>).mock
74
+ .calls[0][0] as string;
75
+ expect(url).toContain("prices.azure.com/api/retail/prices");
76
+ expect(url).toContain("currencyCode=EUR");
77
+ expect(url).toContain("api-version=");
78
+ // URLSearchParams encodes spaces as `+` (form-urlencoded), so we
79
+ // convert them back before decoding the filter expression.
80
+ const decoded = decodeURIComponent(url.replace(/\+/g, " "));
81
+ expect(decoded).toContain("$filter=serviceName eq 'Virtual Machines'");
82
+ });
83
+
84
+ it("follows NextPageLink across multiple pages", async () => {
85
+ const page1 = {
86
+ Count: 1,
87
+ Items: [
88
+ {
89
+ currencyCode: "EUR",
90
+ retailPrice: 1,
91
+ type: "Consumption",
92
+ unitOfMeasure: "1 Hour",
93
+ unitPrice: 1,
94
+ },
95
+ ],
96
+ NextPageLink: "https://prices.azure.com/api/retail/prices?next=2",
97
+ };
98
+ const page2 = {
99
+ Count: 1,
100
+ Items: [
101
+ {
102
+ currencyCode: "EUR",
103
+ retailPrice: 2,
104
+ type: "Consumption",
105
+ unitOfMeasure: "1 Hour",
106
+ unitPrice: 2,
107
+ },
108
+ ],
109
+ NextPageLink: null,
110
+ };
111
+ const fetchImpl = vi
112
+ .fn<FetchLike>()
113
+ .mockResolvedValueOnce({
114
+ json: async () => page1,
115
+ ok: true,
116
+ status: 200,
117
+ statusText: "OK",
118
+ })
119
+ .mockResolvedValueOnce({
120
+ json: async () => page2,
121
+ ok: true,
122
+ status: 200,
123
+ statusText: "OK",
124
+ });
125
+
126
+ const client = new PricingClient({
127
+ cache: new DiskCache({ dir }),
128
+ fetch: fetchImpl,
129
+ });
130
+ const items = await client.query("anything");
131
+
132
+ expect(items.map((i) => i.unitPrice)).toEqual([1, 2]);
133
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
134
+ });
135
+
136
+ it("serves a cached response without hitting the network on the second call", async () => {
137
+ const body = await loadFixture();
138
+ const fetchImpl = makeOkFetch(body);
139
+ const cache = new DiskCache({ dir });
140
+ const client = new PricingClient({ cache, fetch: fetchImpl });
141
+
142
+ const first = await client.query("filter-a");
143
+ const second = await client.query("filter-a");
144
+
145
+ expect(first).toEqual(second);
146
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
147
+ });
148
+
149
+ it("keeps cache entries scoped by currency", async () => {
150
+ const body = await loadFixture();
151
+ const fetchImpl = makeOkFetch(body);
152
+ const cache = new DiskCache({ dir });
153
+ const eur = new PricingClient({
154
+ cache,
155
+ currencyCode: "EUR",
156
+ fetch: fetchImpl,
157
+ });
158
+ const usd = new PricingClient({
159
+ cache,
160
+ currencyCode: "USD",
161
+ fetch: fetchImpl,
162
+ });
163
+
164
+ await eur.query("same-filter");
165
+ await usd.query("same-filter");
166
+
167
+ // Two distinct cache keys → two API round-trips, no cross-contamination.
168
+ expect(fetchImpl).toHaveBeenCalledTimes(2);
169
+ });
170
+
171
+ it("throws a descriptive error on a non-2xx response", async () => {
172
+ const fetchImpl: FetchLike = vi.fn(async () => ({
173
+ json: async () => ({}),
174
+ ok: false,
175
+ status: 503,
176
+ statusText: "Service Unavailable",
177
+ }));
178
+ const client = new PricingClient({
179
+ cache: new DiskCache({ dir }),
180
+ fetch: fetchImpl,
181
+ });
182
+
183
+ await expect(client.query("x")).rejects.toThrow(/503/);
184
+ });
185
+
186
+ it("throws when the response body fails schema validation", async () => {
187
+ const fetchImpl = makeOkFetch({ wrong: "shape" });
188
+ const client = new PricingClient({
189
+ cache: new DiskCache({ dir }),
190
+ fetch: fetchImpl,
191
+ });
192
+
193
+ await expect(client.query("x")).rejects.toThrow();
194
+ });
195
+
196
+ it("throws a descriptive error when the response body is not JSON", async () => {
197
+ const fetchImpl: FetchLike = vi.fn(async () => ({
198
+ json: async () => {
199
+ throw new SyntaxError("Unexpected token");
200
+ },
201
+ ok: true,
202
+ status: 200,
203
+ statusText: "OK",
204
+ }));
205
+ const client = new PricingClient({
206
+ cache: new DiskCache({ dir }),
207
+ fetch: fetchImpl,
208
+ });
209
+
210
+ await expect(client.query("x")).rejects.toThrow(/invalid JSON/);
211
+ });
212
+ });
@@ -0,0 +1,51 @@
1
+ {
2
+ "BillingCurrency": "EUR",
3
+ "Count": 2,
4
+ "Items": [
5
+ {
6
+ "currencyCode": "EUR",
7
+ "tierMinimumUnits": 0,
8
+ "retailPrice": 0.057,
9
+ "unitPrice": 0.057,
10
+ "armRegionName": "westeurope",
11
+ "location": "EU West",
12
+ "effectiveStartDate": "2024-01-01T00:00:00Z",
13
+ "meterId": "00000000-0000-0000-0000-000000000001",
14
+ "meterName": "B2s",
15
+ "productId": "DZH318Z0BPVW",
16
+ "skuId": "DZH318Z0BPVW/0001",
17
+ "productName": "Virtual Machines BS Series",
18
+ "skuName": "B2s",
19
+ "serviceName": "Virtual Machines",
20
+ "serviceId": "DZH318Z0BPVW",
21
+ "serviceFamily": "Compute",
22
+ "unitOfMeasure": "1 Hour",
23
+ "type": "Consumption",
24
+ "isPrimaryMeterRegion": true,
25
+ "armSkuName": "Standard_B2s"
26
+ },
27
+ {
28
+ "currencyCode": "EUR",
29
+ "tierMinimumUnits": 0,
30
+ "retailPrice": 0.0285,
31
+ "unitPrice": 0.0285,
32
+ "armRegionName": "westeurope",
33
+ "location": "EU West",
34
+ "effectiveStartDate": "2024-01-01T00:00:00Z",
35
+ "meterId": "00000000-0000-0000-0000-000000000002",
36
+ "meterName": "B2s Low Priority",
37
+ "productId": "DZH318Z0BPVW",
38
+ "skuId": "DZH318Z0BPVW/0002",
39
+ "productName": "Virtual Machines BS Series",
40
+ "skuName": "B2s Spot",
41
+ "serviceName": "Virtual Machines",
42
+ "serviceId": "DZH318Z0BPVW",
43
+ "serviceFamily": "Compute",
44
+ "unitOfMeasure": "1 Hour",
45
+ "type": "Consumption",
46
+ "isPrimaryMeterRegion": true,
47
+ "armSkuName": "Standard_B2s"
48
+ }
49
+ ],
50
+ "NextPageLink": null
51
+ }