cloudbroker-sdk 0.5.0 → 0.7.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,270 @@
1
+ /**
2
+ * Teste do wrapGemini contra o cliente REAL do @google/genai — não contra uma
3
+ * fake escrita à mão.
4
+ *
5
+ * Por que existe: a mesma razão do test-azure-real.js. Uma fake desenhada pra
6
+ * casar com a implementação não reprova suposição errada sobre o SDK de
7
+ * verdade. E aqui havia uma suposição especialmente perigosa: a doc pública do
8
+ * Gemini mostra o usage em snake_case (`total_input_tokens`), que pertence a
9
+ * outra superfície da API — o `generateContent` devolve camelCase
10
+ * (`promptTokenCount`). Ter errado isso daria token zero em silêncio, sem
11
+ * nenhum erro. Este arquivo trava os nomes contra os tipos do pacote instalado.
12
+ *
13
+ * Não faz chamada de rede: a chave é sintética e o método original é
14
+ * substituído por um stub antes do wrap ser exercitado.
15
+ *
16
+ * Execute: node src/test-gemini-real.js
17
+ */
18
+ import assert from "node:assert";
19
+ import { readFileSync } from "node:fs";
20
+ import { createRequire } from "node:module";
21
+ import { GoogleGenAI } from "@google/genai";
22
+ import { CloudBrokerAI } from "./wrapper.js";
23
+ import { priceGemini, PRICING } from "./pricing.js";
24
+
25
+ const require = createRequire(import.meta.url);
26
+
27
+ // O pacote não expõe "./package.json" em `exports`, então o caminho dos tipos
28
+ // é derivado do entry point resolvido (.../dist/node/index.cjs → .../dist).
29
+ function typingsPath() {
30
+ const entry = require.resolve("@google/genai");
31
+ return entry.slice(0, entry.indexOf("/dist/") + "/dist/".length) + "genai.d.ts";
32
+ }
33
+
34
+ let ok = 0, fail = 0;
35
+ const t = (name, fn) => {
36
+ try { fn(); console.log(`✅ ${name}`); ok++; }
37
+ catch (e) { console.error(`❌ ${name}\n ${e.message}`); fail++; }
38
+ };
39
+ const ta = async (name, fn) => {
40
+ try { await fn(); console.log(`✅ ${name}`); ok++; }
41
+ catch (e) { console.error(`❌ ${name}\n ${e.message}`); fail++; }
42
+ };
43
+
44
+ console.log("🧪 wrapGemini contra o SDK real do Google (sem rede)\n");
45
+
46
+ // ── O contrato de nomes que a implementação assume ───────────────────────────
47
+ t("usageMetadata do @google/genai é camelCase, como a implementação assume", () => {
48
+ const dts = readFileSync(typingsPath(), "utf8");
49
+ const decl = dts.slice(dts.indexOf("declare class GenerateContentResponseUsageMetadata"));
50
+ const body = decl.slice(0, decl.indexOf("\n}"));
51
+ for (const field of [
52
+ "promptTokenCount", "candidatesTokenCount", "thoughtsTokenCount",
53
+ "cachedContentTokenCount", "toolUsePromptTokenCount", "totalTokenCount",
54
+ "promptTokensDetails",
55
+ ]) {
56
+ assert.ok(body.includes(`${field}?:`), `campo ${field} sumiu do SDK — a extração de token quebra em silêncio`);
57
+ }
58
+ });
59
+
60
+ t("promptTokenCount continua incluindo os tokens cacheados", () => {
61
+ // A fórmula subtrai `cached` de `prompt`. Se o SDK mudar essa semântica, a
62
+ // subtração passa a subfaturar — e nada mais no código avisaria.
63
+ const dts = readFileSync(typingsPath(), "utf8");
64
+ const i = dts.indexOf("promptTokenCount?:");
65
+ const doc = dts.slice(Math.max(0, i - 400), i);
66
+ assert.ok(
67
+ /also includes the number of tokens in the cached content/i.test(doc),
68
+ "a doc do campo mudou — revisar `fullPriceInput = prompt - cached` em pricing.js",
69
+ );
70
+ });
71
+
72
+ // ── Detecção do cliente real ─────────────────────────────────────────────────
73
+ const raw = new GoogleGenAI({ apiKey: "cb-test-synthetic" });
74
+
75
+ t("cliente real tem a forma que a detecção procura", () => {
76
+ assert.strictEqual(raw.constructor.name, "GoogleGenAI");
77
+ assert.strictEqual(typeof raw.models.generateContent, "function");
78
+ assert.strictEqual(typeof raw.models.generateContentStream, "function");
79
+ });
80
+
81
+ t("não colide com a detecção de OpenAI nem de Anthropic", () => {
82
+ assert.ok(!raw.chat?.completions, "cairia no ramo da OpenAI");
83
+ assert.ok(!raw.messages?.create, "cairia no ramo da Anthropic");
84
+ });
85
+
86
+ // ── Instrumentação ───────────────────────────────────────────────────────────
87
+ // Stub do método original: nenhuma rede, e devolve um usageMetadata realista.
88
+ const tracked = [];
89
+ function makeClient(usageMetadata, { modelVersion = "gemini-2.5-flash" } = {}) {
90
+ const c = new GoogleGenAI({ apiKey: "cb-test-synthetic" });
91
+ let seenParams = null;
92
+ c.models.generateContent = async (params) => {
93
+ seenParams = params;
94
+ return { modelVersion, usageMetadata, candidates: [] };
95
+ };
96
+ c.models.generateContentStream = async (params) => {
97
+ seenParams = params;
98
+ return (async function* () {
99
+ yield { modelVersion, candidates: [] };
100
+ yield { modelVersion, usageMetadata, candidates: [] };
101
+ })();
102
+ };
103
+ const wrapped = CloudBrokerAI.wrap(c, { apiKey: "cb-test-key", product: "checkout" });
104
+ // Intercepta o envio pra inspecionar o evento sem rede.
105
+ wrapped.trackDeploy = wrapped.trackDeploy; // preserva
106
+ return { wrapped, getParams: () => seenParams };
107
+ }
108
+
109
+ // O client.track empurra pra fila e tenta flush em background; aqui trocamos o
110
+ // CloudBrokerClient por captura direta, via monkey-patch do fetch global.
111
+ const realFetch = globalThis.fetch;
112
+ globalThis.fetch = async (url, init) => {
113
+ try {
114
+ const body = JSON.parse(init?.body || "{}");
115
+ for (const ev of (body.events || [])) tracked.push(ev);
116
+ } catch { /* ignora */ }
117
+ return { ok: true, status: 200, json: async () => ({ ok: true }) };
118
+ };
119
+
120
+ await ta("generateContent registra tokens reais e não altera a resposta", async () => {
121
+ tracked.length = 0;
122
+ const { wrapped } = makeClient({
123
+ promptTokenCount: 1000, candidatesTokenCount: 500, totalTokenCount: 1500,
124
+ });
125
+ const res = await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" });
126
+ assert.strictEqual(res.modelVersion, "gemini-2.5-flash", "resposta original tem que passar intacta");
127
+ await new Promise(r => setTimeout(r, 30));
128
+ const ev = tracked.at(-1);
129
+ assert.ok(ev, "nenhum evento registrado");
130
+ assert.strictEqual(ev.provider, "gemini");
131
+ assert.strictEqual(ev.inputTokens, 1000);
132
+ assert.strictEqual(ev.outputTokens, 500);
133
+ assert.strictEqual(ev.costUSD, priceGemini("gemini-2.5-flash", { promptTokenCount: 1000, candidatesTokenCount: 500 }).costUSD);
134
+ });
135
+
136
+ await ta("thinking tokens entram no output (senão o custo sai muito menor)", async () => {
137
+ tracked.length = 0;
138
+ const { wrapped } = makeClient({
139
+ promptTokenCount: 100, candidatesTokenCount: 50, thoughtsTokenCount: 2000,
140
+ });
141
+ await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" });
142
+ await new Promise(r => setTimeout(r, 30));
143
+ const ev = tracked.at(-1);
144
+ assert.strictEqual(ev.outputTokens, 2050, "thoughts precisa somar ao output");
145
+ assert.strictEqual(ev.thoughtsTokens, 2000);
146
+ const ingenuo = 100 * PRICING.gemini["gemini-2.5-flash"].input + 50 * PRICING.gemini["gemini-2.5-flash"].output;
147
+ assert.ok(ev.costUSD > ingenuo * 10, "ignorar thoughts subfaturaria em mais de 10x");
148
+ });
149
+
150
+ await ta("tokens cacheados saem ao preço de cache, não ao preço cheio", async () => {
151
+ tracked.length = 0;
152
+ const { wrapped } = makeClient({
153
+ promptTokenCount: 1000, cachedContentTokenCount: 800, candidatesTokenCount: 100,
154
+ });
155
+ await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" });
156
+ await new Promise(r => setTimeout(r, 30));
157
+ const ev = tracked.at(-1);
158
+ const r = PRICING.gemini["gemini-2.5-flash"];
159
+ assert.strictEqual(ev.cachedTokens, 800);
160
+ assert.ok(Math.abs(ev.costUSD - (200 * r.input + 800 * r.cached + 100 * r.output)) < 1e-9);
161
+ const cheio = 1000 * r.input + 100 * r.output;
162
+ assert.ok(ev.costUSD < cheio, "cobrar o prompt inteiro a preço cheio superfaturaria");
163
+ });
164
+
165
+ await ta("modelo fora da tabela registra tokens sem inventar preço", async () => {
166
+ tracked.length = 0;
167
+ const { wrapped } = makeClient(
168
+ { promptTokenCount: 1000, candidatesTokenCount: 500 },
169
+ { modelVersion: "gemini-9.9-ultra" },
170
+ );
171
+ await wrapped.models.generateContent({ model: "gemini-9.9-ultra", contents: "oi" });
172
+ await new Promise(r => setTimeout(r, 30));
173
+ const ev = tracked.at(-1);
174
+ assert.strictEqual(ev.costUSD, null, "custo tem que ser null, nunca o preço de outro modelo");
175
+ assert.strictEqual(ev.unpriced, true);
176
+ assert.strictEqual(ev.inputTokens, 1000, "os tokens continuam sendo registrados");
177
+ assert.ok(ev.pricingNote?.includes("gemini-9.9-ultra"));
178
+ });
179
+
180
+ await ta("resposta sem usageMetadata não vira custo zero silencioso", async () => {
181
+ tracked.length = 0;
182
+ const { wrapped } = makeClient(undefined);
183
+ await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" });
184
+ await new Promise(r => setTimeout(r, 30));
185
+ const ev = tracked.at(-1);
186
+ assert.strictEqual(ev.totalTokens, 0);
187
+ assert.strictEqual(ev.costUSD, 0, "sem tokens, zero é o valor correto — o caso perigoso é o modelo desconhecido");
188
+ });
189
+
190
+ await ta("cbLabel é aceito no params e NÃO vaza pra API do provider", async () => {
191
+ tracked.length = 0;
192
+ const { wrapped, getParams } = makeClient({ promptTokenCount: 10, candidatesTokenCount: 5 });
193
+ await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi", cbLabel: "checkout:resumo" });
194
+ await new Promise(r => setTimeout(r, 30));
195
+ assert.ok(!("cbLabel" in getParams()), "cbLabel vazou pro SDK do Google");
196
+ assert.strictEqual(tracked.at(-1).label, "checkout:resumo");
197
+ });
198
+
199
+ await ta("cbLabel também funciona no segundo argumento (convenção dos outros wrappers)", async () => {
200
+ tracked.length = 0;
201
+ const { wrapped } = makeClient({ promptTokenCount: 10, candidatesTokenCount: 5 });
202
+ await wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" }, { cbLabel: "billing:fatura" });
203
+ await new Promise(r => setTimeout(r, 30));
204
+ assert.strictEqual(tracked.at(-1).label, "billing:fatura");
205
+ });
206
+
207
+ await ta("streaming acumula o usage do chunk final e entrega todos os chunks", async () => {
208
+ tracked.length = 0;
209
+ const { wrapped } = makeClient({
210
+ promptTokenCount: 300, candidatesTokenCount: 200, thoughtsTokenCount: 100,
211
+ });
212
+ const stream = await wrapped.models.generateContentStream({ model: "gemini-2.5-flash", contents: "oi" });
213
+ let chunks = 0;
214
+ for await (const _ of stream) chunks++;
215
+ await new Promise(r => setTimeout(r, 30));
216
+ const ev = tracked.at(-1);
217
+ assert.strictEqual(chunks, 2, "o consumidor tem que receber os chunks originais");
218
+ assert.strictEqual(ev.streaming, true);
219
+ assert.strictEqual(ev.inputTokens, 300);
220
+ assert.strictEqual(ev.outputTokens, 300, "200 candidates + 100 thoughts");
221
+ });
222
+
223
+ await ta("erro do provider passa intacto e não é engolido pela instrumentação", async () => {
224
+ const c = new GoogleGenAI({ apiKey: "cb-test-synthetic" });
225
+ c.models.generateContent = async () => { throw new Error("429 quota exceeded"); };
226
+ const wrapped = CloudBrokerAI.wrap(c, { apiKey: "cb-test-key" });
227
+ await assert.rejects(
228
+ () => wrapped.models.generateContent({ model: "gemini-2.5-flash", contents: "oi" }),
229
+ /429 quota exceeded/,
230
+ );
231
+ });
232
+
233
+ globalThis.fetch = realFetch;
234
+
235
+ // ── Preço: casos que o wrapper não exercita ──────────────────────────────────
236
+ const near = (a, b) => Math.abs(a - b) < 1e-9;
237
+
238
+ t("2.5 Pro troca de faixa acima de 200k tokens de prompt", () => {
239
+ const base = priceGemini("gemini-2.5-pro", { promptTokenCount: 1000, candidatesTokenCount: 100 });
240
+ assert.ok(near(base.costUSD, 1000 * 0.00000125 + 100 * 0.00001), "abaixo do corte usa preço base");
241
+
242
+ const alto = priceGemini("gemini-2.5-pro", { promptTokenCount: 250000, candidatesTokenCount: 1000 });
243
+ assert.ok(near(alto.costUSD, 250000 * 0.0000025 + 1000 * 0.000015), "acima de 200k dobra");
244
+ });
245
+
246
+ t("áudio na entrada é cobrado à parte do texto", () => {
247
+ const r = PRICING.gemini["gemini-2.5-flash"];
248
+ const p = priceGemini("gemini-2.5-flash", {
249
+ promptTokenCount: 1000,
250
+ promptTokensDetails: [{ modality: "AUDIO", tokenCount: 400 }, { modality: "TEXT", tokenCount: 600 }],
251
+ });
252
+ assert.ok(near(p.costUSD, 600 * r.input + 400 * r.audioInput));
253
+ assert.ok(p.costUSD > 1000 * r.input, "tratar áudio como texto subfaturaria");
254
+ });
255
+
256
+ t("variante com sufixo casa pelo prefixo MAIS LONGO e é marcada aproximada", () => {
257
+ const p = priceGemini("gemini-2.5-flash-lite-preview-06", { promptTokenCount: 1000 });
258
+ assert.strictEqual(p.pricedAs, "gemini-2.5-flash-lite", "não pode cair no 'gemini-2.5-flash', que é mais caro");
259
+ assert.strictEqual(p.approxPricing, true, "preço de variante não listada tem que sair sinalizado");
260
+ });
261
+
262
+ t("modelos 1.5/2.0 removidos não são precificados por engano", () => {
263
+ // Saíram da página oficial; manter preço não verificável seria fabricar custo.
264
+ for (const legado of ["gemini-1.5-pro", "gemini-1.5-flash", "gemini-2.0-flash"]) {
265
+ assert.strictEqual(priceGemini(legado, { promptTokenCount: 100 }).costUSD, null, legado);
266
+ }
267
+ });
268
+
269
+ console.log(`\n${fail === 0 ? "🎉 TODOS PASSARAM" : "💥 FALHAS"}: ${ok} ok, ${fail} fail`);
270
+ process.exitCode = fail === 0 ? 0 : 1;
package/src/test.js CHANGED
@@ -225,5 +225,269 @@ await new Promise((r) => setTimeout(r, 50));
225
225
  assert.strictEqual(capturedAi[1].events[0].label, "finops-advisor:root-cause-agent");
226
226
  console.log("✅ CloudBrokerAI.wrap: label via contexto cbStore (sem cbLabel explícito)");
227
227
 
228
+ // ─── AZURE (wrapAzure — Blob Storage + Cosmos DB) ──────────────────────────────
229
+ console.log("\n☁️ Testando wrapAzure (Blob + Cosmos)...");
230
+
231
+ // 5) Tabela de preços Azure
232
+ const blobW = estimateCloudCost({ provider: "azure", service: "blob", operation: "uploadData", requests: 1 });
233
+ assert.strictEqual(blobW.billingUnit, "blob-write");
234
+ assert.ok(Math.abs(blobW.costUSD - 0.055 / 10_000) < 1e-12, `blob write veio ${blobW.costUSD}`);
235
+
236
+ const blobR = estimateCloudCost({ provider: "azure", service: "blob", operation: "download", requests: 1, bytesOut: 1024 * 1024 * 1024 });
237
+ assert.strictEqual(blobR.billingUnit, "blob-read");
238
+ assert.ok(Math.abs(blobR.costUSD - (0.0044 / 10_000 + 0.087)) < 1e-9, `blob read+transfer veio ${blobR.costUSD}`);
239
+
240
+ const blobDel = estimateCloudCost({ provider: "azure", service: "blob", operation: "delete", requests: 1 });
241
+ assert.strictEqual(blobDel.billingUnit, "blob-free");
242
+ assert.strictEqual(blobDel.costUSD, 0);
243
+
244
+ const blobList = estimateCloudCost({ provider: "azure", service: "blob", operation: "listBlobsFlat", requests: 1 });
245
+ assert.strictEqual(blobList.billingUnit, "blob-list");
246
+
247
+ const cosmosReal = estimateCloudCost({ provider: "azure", service: "cosmosdb", operation: "items.create", requestCharge: 7.43 });
248
+ assert.strictEqual(cosmosReal.billingUnit, "cosmos-ru");
249
+ assert.ok(Math.abs(cosmosReal.costUSD - 7.43 * 0.25 / 1_000_000) < 1e-15, `cosmos RU real veio ${cosmosReal.costUSD}`);
250
+
251
+ const cosmosEst = estimateCloudCost({ provider: "azure", service: "cosmosdb", operation: "items.create" });
252
+ assert.strictEqual(cosmosEst.billingUnit, "cosmos-ru-est");
253
+ assert.ok(Math.abs(cosmosEst.costUSD - 5 * 0.25 / 1_000_000) < 1e-15, `cosmos RU fallback veio ${cosmosEst.costUSD}`);
254
+ console.log("✅ estimateCloudCost azure: blob write/read+transfer/delete-free/list, cosmos RU real/fallback");
255
+
256
+ // 5b) Região sem tabela de preço NÃO pode ser precificada como eastus2 — um app
257
+ // em brazilsouth receberia o custo de outra região apresentado como o dele.
258
+ const brs = estimateCloudCost({ provider: "azure", service: "blob", operation: "uploadData", region: "brazilsouth" });
259
+ assert.strictEqual(brs.costUSD, null, `região sem preço deve dar custo null, veio ${brs.costUSD}`);
260
+ assert.strictEqual(brs.billingUnit, "blob-region-unpriced");
261
+ console.log("✅ região Azure sem tabela: custo null + unidade explícita (não finge eastus2)");
262
+
263
+ // 6) Blob — cadeia de fábricas, cbLabel não vaza, bytes medidos do argumento certo
264
+ captured.length = 0;
265
+ globalThis.fetch = async (url, opts) => {
266
+ if (String(url).includes("/api/cloud/events")) captured.push(JSON.parse(opts.body));
267
+ return { ok: true, status: 200, json: async () => ({}) };
268
+ };
269
+
270
+ let sawBlobOptions, sawUploadArgs;
271
+ class BlockBlobClient {
272
+ async uploadData(data, options) { sawBlobOptions = options; return { etag: "0x1" }; }
273
+ async download() { return { contentLength: 4096 }; }
274
+ // getProperties devolve o TAMANHO DO BLOB, mas não transfere o blob.
275
+ async getProperties() { return { contentLength: 5 * 1024 * 1024 * 1024 }; }
276
+ async uploadBlockBlob(...args) { sawUploadArgs = args; return { etag: "0x2" }; }
277
+ async setMetadata(meta, options) { return { meta, options }; }
278
+ }
279
+ class ContainerClient {
280
+ getBlockBlobClient(_name) { return new BlockBlobClient(); }
281
+ listBlobsFlat() {
282
+ let i = 0;
283
+ return { async next() { return i++ < 2 ? { done: false, value: { name: `b${i}` } } : { done: true }; },
284
+ [Symbol.asyncIterator]() { return this; } };
285
+ }
286
+ async uploadBlockBlob(...args) { sawUploadArgs = args; return { etag: "0x2" }; }
287
+ }
288
+ class BlobServiceClient {
289
+ getContainerClient(_name) { return new ContainerClient(); }
290
+ }
291
+
292
+ const blobSvc = CloudBrokerCloud.wrapAzure(new BlobServiceClient(), {
293
+ apiKey: "cb-test-key", product: "checkout", team: "mobile", debug: true,
294
+ });
295
+ const blockBlob = blobSvc.getContainerClient("docs").getBlockBlobClient("nf.pdf");
296
+ const upRes = await blockBlob.uploadData(Buffer.from("hello azure"), { cbLabel: "upload-nf-azure" });
297
+ assert.strictEqual(upRes.etag, "0x1", "resultado original deve ser preservado");
298
+ // O argumento NUNCA é removido: setMetadata() sem argumento apaga os metadados
299
+ // do blob. O options segue existindo, só sem as chaves cb*.
300
+ assert.deepStrictEqual(sawBlobOptions, {}, "options deve chegar sem cb*, mas deve chegar");
301
+
302
+ const dlRes = await blockBlob.download();
303
+ assert.strictEqual(dlRes.contentLength, 4096);
304
+
305
+ await new Promise(r => setTimeout(r, 50));
306
+ const azEvents = captured.flatMap(c => c.events);
307
+ assert.strictEqual(azEvents.length, 2, `esperado 2 eventos (upload+download), veio ${azEvents.length}`);
308
+ const [evUp, evDl] = azEvents;
309
+ assert.strictEqual(evUp.provider, "azure");
310
+ assert.strictEqual(evUp.service, "blob");
311
+ assert.strictEqual(evUp.operation, "uploadData");
312
+ assert.strictEqual(evUp.label, "upload-nf-azure");
313
+ assert.strictEqual(evUp.product, "checkout");
314
+ assert.strictEqual(evUp.bytesIn, Buffer.byteLength("hello azure"));
315
+ assert.strictEqual(evUp.billingUnit, "blob-write");
316
+ assert.strictEqual(evDl.operation, "download");
317
+ assert.strictEqual(evDl.bytesOut, 4096);
318
+ assert.strictEqual(evDl.billingUnit, "blob-read");
319
+ console.log("✅ wrapAzure blob: cadeia de fábricas, cb* sem vazar, options preservado, bytes in/out");
320
+
321
+ // 6b) getProperties NÃO pode cobrar egress do tamanho do blob (custo inflado)
322
+ captured.length = 0;
323
+ await blockBlob.getProperties();
324
+ await new Promise(r => setTimeout(r, 50));
325
+ const evProps = captured.flatMap(c => c.events)[0];
326
+ assert.strictEqual(evProps.operation, "getProperties");
327
+ assert.strictEqual(evProps.bytesOut, 0, `getProperties não transfere o blob; bytesOut veio ${evProps.bytesOut}`);
328
+ assert.ok(evProps.costUSD < 1e-6, `custo de metadado deve ser desprezível, veio ${evProps.costUSD}`);
329
+ console.log("✅ getProperties: sem egress do tamanho do blob (custo de metadado, não de 5 GB)");
330
+
331
+ // 6c) bytesIn vem do argumento de CORPO declarado, não da primeira string
332
+ // (em uploadBlockBlob a primeira string é o NOME do blob)
333
+ captured.length = 0;
334
+ await blobSvc.getContainerClient("docs").uploadBlockBlob("relatorio-financeiro-2026-07.pdf", Buffer.alloc(10 * 1024 * 1024), 10 * 1024 * 1024);
335
+ await new Promise(r => setTimeout(r, 50));
336
+ const evBlockUp = captured.flatMap(c => c.events)[0];
337
+ assert.strictEqual(evBlockUp.bytesIn, 10 * 1024 * 1024, `bytesIn deve ser o corpo (10MB), veio ${evBlockUp.bytesIn}`);
338
+ console.log("✅ bytesIn: medido do argumento de corpo, não do nome do blob");
339
+
340
+ // 6d) list é LAZY — nenhum HTTP na criação do iterator. Evento só quando a
341
+ // página é de fato buscada (senão emitia custo de operação que nunca ocorreu).
342
+ captured.length = 0;
343
+ const neverUsed = blobSvc.getContainerClient("docs").listBlobsFlat();
344
+ await new Promise(r => setTimeout(r, 50));
345
+ assert.strictEqual(captured.length, 0, "iterator criado e não consumido não pode gerar custo");
346
+ for await (const _b of neverUsed) { /* consome */ }
347
+ await new Promise(r => setTimeout(r, 50));
348
+ const listEvents = captured.flatMap(c => c.events);
349
+ assert.ok(listEvents.length >= 1, "consumir o iterator deve gerar evento de list");
350
+ assert.strictEqual(listEvents[0].billingUnit, "blob-list");
351
+ console.log("✅ list: sem custo fabricado — evento só quando a página é buscada");
352
+
353
+ // 7) Cosmos — requestCharge REAL vira custo; iterator de query rastreado
354
+ captured.length = 0;
355
+ let sawCreateArgs;
356
+ class Item {
357
+ async read() { return { requestCharge: 1.0, resource: { id: "x" } }; }
358
+ }
359
+ class Container {
360
+ constructor() {
361
+ this.items = {
362
+ create: async (...args) => { sawCreateArgs = args; return { requestCharge: 7.43, resource: { id: "y" } }; },
363
+ query: (_q) => ({ fetchAll: async () => ({ requestCharge: 3.2, resources: [] }) }),
364
+ bulk: async (_ops) => ([{ requestCharge: 5 }, { requestCharge: 6 }]),
365
+ };
366
+ }
367
+ item(_id, _pk) { return new Item(); }
368
+ // control-plane: lê a definição do CONTAINER, não de um item
369
+ async read() { return { requestCharge: 2.0, resource: { id: "orders" } }; }
370
+ }
371
+ class Database {
372
+ container(_id) { return new Container(); }
373
+ async read() { return { requestCharge: 1.5, resource: { id: "db" } }; }
374
+ }
375
+ class CosmosClient { database(_id) { return new Database(); } }
376
+
377
+ const cosmos = CloudBrokerCloud.wrapAzure(new CosmosClient(), {
378
+ apiKey: "cb-test-key", product: "checkout", team: "mobile",
379
+ });
380
+ const cont = cosmos.database("db").container("orders");
381
+ const createRes = await cont.items.create({ id: "y" }, { cbLabel: "criar-pedido" });
382
+ assert.strictEqual(createRes.resource.id, "y");
383
+ await cont.item("x", "pk").read();
384
+ await cont.items.query("SELECT * FROM c").fetchAll();
385
+
386
+ await new Promise(r => setTimeout(r, 50));
387
+ const cosmosEvents = captured.flatMap(c => c.events);
388
+ assert.strictEqual(cosmosEvents.length, 3, `esperado 3 eventos cosmos, veio ${cosmosEvents.length}`);
389
+ const [evCreate, evRead, evQuery] = cosmosEvents;
390
+ assert.strictEqual(evCreate.service, "cosmosdb");
391
+ assert.strictEqual(evCreate.operation, "items.create");
392
+ assert.strictEqual(evCreate.label, "criar-pedido");
393
+ assert.strictEqual(evCreate.billingUnit, "cosmos-ru");
394
+ assert.ok(Math.abs(evCreate.costUSD - 7.43 * 0.25 / 1_000_000) < 1e-15, `custo RU real veio ${evCreate.costUSD}`);
395
+ assert.strictEqual(evRead.operation, "item.read");
396
+ assert.strictEqual(evQuery.operation, "items.query.fetchAll");
397
+ assert.ok(Math.abs(evQuery.costUSD - 3.2 * 0.25 / 1_000_000) < 1e-15, `custo do query veio ${evQuery.costUSD}`);
398
+ console.log("✅ wrapAzure cosmos: requestCharge real → custo, item.read, query iterator");
399
+
400
+ // 7b) O DOCUMENTO do cliente nunca é inspecionado nem alterado — um campo
401
+ // chamado cbLabel no corpo tem que ser gravado como veio.
402
+ captured.length = 0;
403
+ const docComCampoColidindo = { id: "z", cbLabel: "campo-de-negocio-do-cliente", valor: 10 };
404
+ await cont.items.create(docComCampoColidindo);
405
+ assert.deepStrictEqual(
406
+ sawCreateArgs[0],
407
+ { id: "z", cbLabel: "campo-de-negocio-do-cliente", valor: 10 },
408
+ "documento do cliente não pode perder campo por colisão de nome",
409
+ );
410
+ await new Promise(r => setTimeout(r, 50));
411
+ assert.strictEqual(captured.flatMap(c => c.events)[0].label, null, "campo do corpo não vira label");
412
+ console.log("✅ documento do cliente intacto: cb* no corpo não é lido nem removido");
413
+
414
+ // 7c) Container.read()/Database.read() são control-plane — não podem sair
415
+ // rotulados como item.read (operação errada na tela de custo por trecho).
416
+ captured.length = 0;
417
+ await cont.read();
418
+ await cosmos.database("db").read();
419
+ await new Promise(r => setTimeout(r, 50));
420
+ const cpEvents = captured.flatMap(c => c.events);
421
+ assert.strictEqual(cpEvents[0].operation, "container.read", `veio ${cpEvents[0].operation}`);
422
+ assert.strictEqual(cpEvents[1].operation, "database.read", `veio ${cpEvents[1].operation}`);
423
+ console.log("✅ cosmos: container.read/database.read não são confundidos com item.read");
424
+
425
+ // 7d) bulk soma o requestCharge de cada operação (senão 1000 escritas = 1 leitura)
426
+ captured.length = 0;
427
+ await cont.items.bulk([{ operationType: "Create" }, { operationType: "Create" }]);
428
+ await new Promise(r => setTimeout(r, 50));
429
+ const evBulk = captured.flatMap(c => c.events)[0];
430
+ assert.strictEqual(evBulk.operation, "items.bulk");
431
+ assert.strictEqual(evBulk.billingUnit, "cosmos-ru", "bulk deve usar RU medida, não estimativa");
432
+ assert.ok(Math.abs(evBulk.costUSD - 11 * 0.25 / 1_000_000) < 1e-15, `bulk deve somar 5+6 RU, veio ${evBulk.costUSD}`);
433
+ console.log("✅ cosmos bulk: RUs somadas por operação");
434
+
435
+ // 8) Robustez — a instrumentação NUNCA pode quebrar a aplicação do cliente.
436
+ // Este é o requisito mais importante: o SDK roda dentro da produção dele.
437
+ captured.length = 0;
438
+ class BlobQuebrado {
439
+ async uploadData() {
440
+ // Resposta com propriedade lazy que lança ao ser lida (padrão em SDKs
441
+ // de streaming). A operação JÁ ACONTECEU no Azure quando isso é lido.
442
+ return { etag: "ok", get contentLength() { throw new Error("propriedade lazy do SDK lançou"); } };
443
+ }
444
+ }
445
+ class ContainerQuebrado { getBlockBlobClient() { return new BlobQuebrado(); } }
446
+ class BlobServiceClient2 { getContainerClient() { return new ContainerQuebrado(); } }
447
+ Object.defineProperty(BlobServiceClient2, "name", { value: "BlobServiceClient" });
448
+ Object.defineProperty(ContainerQuebrado, "name", { value: "ContainerClient" });
449
+ Object.defineProperty(BlobQuebrado, "name", { value: "BlockBlobClient" });
450
+
451
+ const svcQuebrado = CloudBrokerCloud.wrapAzure(new BlobServiceClient2(), { apiKey: "cb-test-key" });
452
+ const r8 = await svcQuebrado.getContainerClient("c").getBlockBlobClient("b").uploadData(Buffer.from("x"));
453
+ assert.strictEqual(r8.etag, "ok", "erro na instrumentação não pode virar erro da aplicação do cliente");
454
+ console.log("✅ robustez: exceção na leitura do resultado não vaza pro app do cliente");
455
+
456
+ // options com getter que lança, ANTES da chamada real
457
+ class BlobGetterRuim {
458
+ async uploadData(_d, _o) { return { etag: "ok2" }; }
459
+ }
460
+ Object.defineProperty(BlobGetterRuim, "name", { value: "BlockBlobClient" });
461
+ const bad = CloudBrokerCloud.wrapAzure(new BlobGetterRuim(), { apiKey: "cb-test-key" });
462
+ const optsRuim = Object.defineProperty({}, "cbLabel", { get() { throw new Error("getter ruim"); }, enumerable: true });
463
+ const r8b = await bad.uploadData(Buffer.from("x"), optsRuim);
464
+ assert.strictEqual(r8b.etag, "ok2", "erro ao extrair label não pode impedir a operação real");
465
+ console.log("✅ robustez: falha na extração de label não impede a chamada Azure");
466
+
467
+ // 8b) Client congelado pelo cliente: a atribuição in-place lançaria (strict
468
+ // mode). Instrumentação nunca pode derrubar a app — avisa e devolve original.
469
+ class BlobCongelado { async uploadData() { return { etag: "frozen-ok" }; } }
470
+ Object.defineProperty(BlobCongelado, "name", { value: "BlockBlobClient" });
471
+ const congelado = Object.freeze(new BlobCongelado());
472
+ const warnsFrozen = [];
473
+ const warnFrozenOrig = console.warn;
474
+ console.warn = (...a) => warnsFrozen.push(a.join(" "));
475
+ const wrappedFrozen = CloudBrokerCloud.wrapAzure(congelado, { apiKey: "cb-test-key" });
476
+ console.warn = warnFrozenOrig;
477
+ const rFrozen = await wrappedFrozen.uploadData(Buffer.from("x"));
478
+ assert.strictEqual(rFrozen.etag, "frozen-ok", "client congelado deve seguir funcionando");
479
+ assert.ok(warnsFrozen.some(w => w.includes("congelado")), "deve avisar que não vai rastrear");
480
+ console.log("✅ robustez: client congelado não quebra a aplicação (avisa e não rastreia)");
481
+
482
+ // 9) Client não reconhecido → retorna original intocado (e avisa alto)
483
+ class MinhaClasseQualquer { async foo() { return 42; } }
484
+ const warns = [];
485
+ const origWarn = console.warn;
486
+ console.warn = (...a) => warns.push(a.join(" "));
487
+ const untouched = CloudBrokerCloud.wrapAzure(new MinhaClasseQualquer(), { apiKey: "cb-test-key" });
488
+ console.warn = origWarn;
489
+ assert.strictEqual(await untouched.foo(), 42);
490
+ assert.ok(warns.some(w => w.includes("não reconhecido")), "deve avisar mesmo sem debug:true");
491
+ console.log("✅ wrapAzure: client não reconhecido passa intocado, com aviso incondicional");
228
492
  globalThis.fetch = realFetch;
229
493
  console.log("\n🎉 Todos os testes passaram.");