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.
@@ -1,8 +1,23 @@
1
1
  /**
2
2
  * Tabela de preços de nuvem (curada, MVP) — atribuição de custo a nível de código.
3
- * Escopo MVP: AWS, região us-east-2, serviços S3 e DynamoDB.
3
+ * Escopo: AWS (us-east-2: S3, DynamoDB) e Azure (eastus2: Blob Storage, Cosmos DB).
4
4
  * Componentes: requests + transferência de dados (armazenamento GB-mês fora do MVP).
5
- * Preços em USD. Atualizado: Jun/2026.
5
+ * Preços em USD.
6
+ *
7
+ * PROVENIÊNCIA (importante — não trocar por uma data sem conferir de novo):
8
+ * - AWS (us-east-2, S3/DynamoDB): conferido contra a tabela oficial em Jun/2026.
9
+ * - Azure (eastus2, Blob Hot/LRS + Cosmos serverless): valores de referência
10
+ * NÃO conferidos contra a página oficial de preços. São plausíveis, não
11
+ * verificados — a reconciliação contra o Cost Management é o que mede o
12
+ * desvio real. Conferir antes de tratar o custo Azure como número fechado.
13
+ *
14
+ * Premissas que mudam o resultado (ver README):
15
+ * - Blob: tier Hot, redundância LRS. Cool/Archive e GRS/ZRS têm outro preço.
16
+ * - Cosmos: modelo serverless. Em throughput provisionado a cobrança é por
17
+ * RU/s reservada por hora — modelo que este SDK não representa.
18
+ * - Egress: cobrado sobre todo bytesOut. A Azure não cobra saída intra-região
19
+ * e dá franquia mensal — em app rodando dentro da mesma região, este termo
20
+ * superestima.
6
21
  */
7
22
  export const CLOUD_PRICING = {
8
23
  aws: {
@@ -21,6 +36,24 @@ export const CLOUD_PRICING = {
21
36
  },
22
37
  },
23
38
  },
39
+ azure: {
40
+ eastus2: {
41
+ blob: {
42
+ // Hot tier, LRS — por operação (preço por unidade — já dividido por 10.000).
43
+ // List/Create Container é cobrado na mesma faixa de write pela Azure.
44
+ write: 0.055 / 10_000,
45
+ read: 0.0044 / 10_000,
46
+ list: 0.055 / 10_000,
47
+ // transferência de saída para a internet — 1ª faixa (simplificado), por GB
48
+ transferOutPerGB: 0.087,
49
+ },
50
+ cosmosdb: {
51
+ // serverless — por 1M Request Units. O custo real vem do requestCharge
52
+ // (RUs) que o próprio Cosmos retorna em cada resposta.
53
+ perMillionRU: 0.25,
54
+ },
55
+ },
56
+ },
24
57
  };
25
58
 
26
59
  const GB = 1024 * 1024 * 1024;
@@ -39,21 +72,99 @@ const DDB_READ_OPS = new Set([
39
72
  "GetItem", "Query", "Scan", "BatchGetItem", "TransactGetItems",
40
73
  ]);
41
74
 
75
+ /**
76
+ * Blob Storage — FONTE ÚNICA de "o que é interceptado" e "como é cobrado".
77
+ * O wrapper (cloud-wrapper.js) itera esta mesma tabela pra saber quais métodos
78
+ * embrulhar, então é impossível um método ser precificado sem ser rastreado
79
+ * (ou o contrário).
80
+ *
81
+ * - `kind` — classe de cobrança da Azure (write/read/list/free)
82
+ * - `optIdx` — índice a partir do qual o options bag pode aparecer. Protege
83
+ * argumentos de DADO do cliente de serem inspecionados/alterados.
84
+ * - `bodyIdx` — índice do argumento de corpo (pra medir bytesIn). null = não medido.
85
+ * - `transfers` — a operação de fato trafega o conteúdo (conta egress).
86
+ * `getProperties`/`exists` devolvem contentLength mas NÃO
87
+ * transferem o blob — contá-lo cobrava egress de um GET de
88
+ * metadado (erro de ordens de grandeza).
89
+ */
90
+ export const AZURE_BLOB_OPS = {
91
+ upload: { kind: "write", optIdx: 2, bodyIdx: 0 },
92
+ uploadData: { kind: "write", optIdx: 1, bodyIdx: 0 },
93
+ uploadFile: { kind: "write", optIdx: 1, bodyIdx: null },
94
+ uploadStream: { kind: "write", optIdx: 3, bodyIdx: null },
95
+ uploadBlockBlob: { kind: "write", optIdx: 3, bodyIdx: 1 },
96
+ appendBlock: { kind: "write", optIdx: 2, bodyIdx: 0 },
97
+ uploadPages: { kind: "write", optIdx: 3, bodyIdx: 0 },
98
+ stageBlock: { kind: "write", optIdx: 3, bodyIdx: 1 },
99
+ commitBlockList: { kind: "write", optIdx: 1, bodyIdx: null },
100
+ syncCopyFromURL: { kind: "write", optIdx: 1, bodyIdx: null },
101
+ create: { kind: "write", optIdx: 0, bodyIdx: null },
102
+ createIfNotExists: { kind: "write", optIdx: 0, bodyIdx: null },
103
+ setMetadata: { kind: "write", optIdx: 1, bodyIdx: null },
104
+ setHTTPHeaders: { kind: "write", optIdx: 1, bodyIdx: null },
105
+
106
+ download: { kind: "read", optIdx: 0, bodyIdx: null, transfers: true },
107
+ downloadToBuffer: { kind: "read", optIdx: 0, bodyIdx: null, transfers: true },
108
+ downloadToFile: { kind: "read", optIdx: 1, bodyIdx: null, transfers: true },
109
+ exists: { kind: "read", optIdx: 0, bodyIdx: null },
110
+ getProperties: { kind: "read", optIdx: 0, bodyIdx: null },
111
+
112
+ // Grátis no tier Hot — evento é emitido mesmo assim (custo 0), pra operação
113
+ // não sumir do relatório.
114
+ delete: { kind: "free", optIdx: 0, bodyIdx: null },
115
+ deleteIfExists: { kind: "free", optIdx: 0, bodyIdx: null },
116
+ deleteBlob: { kind: "free", optIdx: 1, bodyIdx: null },
117
+ };
118
+
119
+ /** list* devolvem iterator LAZY — cobrados como write pela Azure. */
120
+ export const AZURE_BLOB_ITERATOR_OPS = ["listBlobsFlat", "listBlobsByHierarchy", "listContainers"];
121
+
122
+ /** read/replace/delete/patch existem em Item, Container E Database. */
123
+ export const AZURE_COSMOS_NODE_OPS = {
124
+ read: { optIdx: 0 },
125
+ replace: { optIdx: 1 },
126
+ delete: { optIdx: 0 },
127
+ patch: { optIdx: 1 },
128
+ };
129
+ export const AZURE_COSMOS_ITEMS_OPS = {
130
+ create: { optIdx: 1 },
131
+ upsert: { optIdx: 1 },
132
+ batch: { optIdx: 1 },
133
+ bulk: { optIdx: 1 },
134
+ };
135
+ export const AZURE_COSMOS_ITEMS_ITERATOR_OPS = {
136
+ query: { optIdx: 1 },
137
+ readAll: { optIdx: 0 },
138
+ };
139
+
140
+ // Fallback de RU quando a resposta não traz requestCharge (raro em resposta
141
+ // real). Sai marcado como `cosmos-ru-est`, distinto de `cosmos-ru` medido.
142
+ const COSMOS_WRITE_SUFFIX = /\.(create|upsert|replace|delete|patch|batch|bulk)(\.|$)/;
143
+ const COSMOS_FALLBACK_RU = { write: 5, read: 1 };
144
+
42
145
  /**
43
146
  * Classifica a operação e estima o custo USD de uma única chamada.
44
- * MVP: aproxima 1 unidade de capacidade por request (não conhece o tamanho do item
45
- * com precisão); a reconciliação contra o Cost Explorer corrige o desvio agregado.
147
+ * AWS: aproxima 1 unidade de capacidade por request (não conhece o tamanho do
148
+ * item com precisão). Azure Cosmos: usa o requestCharge (RUs) REAL retornado
149
+ * pelo servidor quando disponível. A reconciliação contra Cost Explorer/Cost
150
+ * Management corrige o desvio agregado.
46
151
  *
47
152
  * @param {object} p
48
- * @param {string} p.service — "s3" | "dynamodb"
49
- * @param {string} p.operation — nome da operação sem o sufixo "Command" (ex: "PutObject")
153
+ * @param {string} [p.provider="aws"] — "aws" | "azure"
154
+ * @param {string} p.service — "s3" | "dynamodb" | "blob" | "cosmosdb"
155
+ * @param {string} p.operation — AWS: nome da operação sem "Command" (ex: "PutObject");
156
+ * Azure: nome do método interceptado (ex: "upload", "items.create")
50
157
  * @param {number} [p.requests=1]
51
- * @param {number} [p.bytesOut=0] — bytes transferidos de saída (ex: ContentLength de um GetObject)
52
- * @param {string} [p.region="us-east-2"]
158
+ * @param {number} [p.bytesOut=0] — bytes transferidos de saída
159
+ * @param {number} [p.requestCharge] — RUs reais da resposta do Cosmos (Azure)
160
+ * @param {string} [p.region] — default us-east-2 (AWS) / eastus2 (Azure)
53
161
  * @returns {{ costUSD:number, billingUnit:string }}
54
162
  */
55
- export function estimateCloudCost({ service, operation, requests = 1, bytesOut = 0, region = "us-east-2" }) {
56
- const regionTable = CLOUD_PRICING.aws[region] || CLOUD_PRICING.aws["us-east-2"];
163
+ export function estimateCloudCost({ provider = "aws", service, operation, requests = 1, bytesOut = 0, requestCharge, region }) {
164
+ if (provider === "azure") {
165
+ return estimateAzureCost({ service, operation, requests, bytesOut, requestCharge, region });
166
+ }
167
+ const regionTable = CLOUD_PRICING.aws[region || "us-east-2"] || CLOUD_PRICING.aws["us-east-2"];
57
168
  const svc = (service || "").toLowerCase();
58
169
  let costUSD = 0;
59
170
  let billingUnit = "unknown";
@@ -81,3 +192,47 @@ export function estimateCloudCost({ service, operation, requests = 1, bytesOut =
81
192
 
82
193
  return { costUSD: parseFloat(costUSD.toFixed(8)), billingUnit };
83
194
  }
195
+
196
+ function estimateAzureCost({ service, operation, requests = 1, bytesOut = 0, requestCharge, region }) {
197
+ const regionKey = region || "eastus2";
198
+ const regionTable = CLOUD_PRICING.azure[regionKey];
199
+ const svc = (service || "").toLowerCase();
200
+
201
+ // Região sem tabela de preço: NÃO cair silenciosamente em eastus2 — um app em
202
+ // brazilsouth (o caso provável do cliente-alvo) receberia um número de outra
203
+ // região apresentado como custo dele. Emite o evento com custo nulo e unidade
204
+ // explícita, pra operação aparecer no relatório sem inventar valor.
205
+ if (!regionTable) {
206
+ return { costUSD: null, billingUnit: `${svc || "azure"}-region-unpriced` };
207
+ }
208
+
209
+ let costUSD = 0;
210
+ let billingUnit = "unknown";
211
+
212
+ if (svc === "blob") {
213
+ const t = regionTable.blob;
214
+ if (AZURE_BLOB_ITERATOR_OPS.includes(operation)) {
215
+ costUSD += requests * t.list;
216
+ billingUnit = "blob-list";
217
+ } else {
218
+ const kind = AZURE_BLOB_OPS[operation]?.kind;
219
+ if (kind === "write") { costUSD += requests * t.write; billingUnit = "blob-write"; }
220
+ else if (kind === "read") { costUSD += requests * t.read; billingUnit = "blob-read"; }
221
+ else if (kind === "free") { billingUnit = "blob-free"; }
222
+ else { costUSD += requests * t.read; billingUnit = "blob-other"; }
223
+ }
224
+ if (bytesOut > 0) costUSD += (bytesOut / GB) * t.transferOutPerGB;
225
+ } else if (svc === "cosmosdb") {
226
+ const t = regionTable.cosmosdb;
227
+ if (typeof requestCharge === "number" && requestCharge > 0) {
228
+ costUSD = requestCharge * (t.perMillionRU / 1_000_000);
229
+ billingUnit = "cosmos-ru";
230
+ } else {
231
+ const ru = COSMOS_WRITE_SUFFIX.test(operation || "") ? COSMOS_FALLBACK_RU.write : COSMOS_FALLBACK_RU.read;
232
+ costUSD = requests * ru * (t.perMillionRU / 1_000_000);
233
+ billingUnit = "cosmos-ru-est";
234
+ }
235
+ }
236
+
237
+ return { costUSD: parseFloat(costUSD.toFixed(10)), billingUnit };
238
+ }
package/src/pricing.js CHANGED
@@ -24,9 +24,167 @@ export const PRICING = {
24
24
  "claude-3-sonnet-20240229": { input: 0.000003, output: 0.000015 },
25
25
  "claude-3-haiku-20240307": { input: 0.00000025, output: 0.00000125 },
26
26
  },
27
+ /**
28
+ * Gemini — preços do paid tier em ai.google.dev/gemini-api/docs/pricing,
29
+ * conferidos em 2026-08-02. A página publica por 1M de tokens; aqui, como no
30
+ * resto da tabela, o valor é por token.
31
+ *
32
+ * A conta do Gemini tem três particularidades que um par `{input, output}`
33
+ * plano não expressa — ignorar qualquer uma delas erra o custo de verdade:
34
+ *
35
+ * - `cached` — prompt servido de cache custa ~10x menos. Atenção:
36
+ * `promptTokenCount` JÁ INCLUI os tokens cacheados, então o
37
+ * input a preço cheio é `prompt - cached`. Cobrar o prompt
38
+ * inteiro a preço cheio superfatura em 10x essa parcela.
39
+ * - `audioInput` — áudio na entrada custa mais que texto/imagem/vídeo nos
40
+ * Flash e Flash-Lite. A separação sai de
41
+ * `promptTokensDetails[].modality === "AUDIO"`.
42
+ * - `tierOver` — o 2.5 Pro dobra acima de 200k tokens de prompt.
43
+ *
44
+ * Os modelos 1.5/2.0 que existiam aqui foram removidos: a página oficial não
45
+ * os lista mais, e manter preço não verificável é fabricar custo. Eles caem
46
+ * agora no caminho de modelo desconhecido (`costUSD: null`), que é honesto.
47
+ *
48
+ * Thinking tokens não aparecem nesta tabela porque não têm preço próprio —
49
+ * a página diz "Output price (including thinking tokens)", ou seja, são
50
+ * cobrados como output. Quem soma é `priceGemini()`.
51
+ */
27
52
  gemini: {
28
- "gemini-1.5-pro": { input: 0.0000035, output: 0.0000105 },
29
- "gemini-1.5-flash": { input: 0.00000035, output: 0.00000105 },
30
- "gemini-2.0-flash": { input: 0.0000001, output: 0.0000004 },
53
+ "gemini-3.6-flash": { input: 0.0000015, output: 0.0000075, cached: 0.00000015 },
54
+ "gemini-3.5-flash": { input: 0.0000015, output: 0.000009, cached: 0.00000015 },
55
+ "gemini-3.5-flash-lite": { input: 0.0000003, output: 0.0000025, cached: 0.00000003 },
56
+ "gemini-3.1-flash-lite": {
57
+ input: 0.00000025, output: 0.0000015, cached: 0.000000025,
58
+ audioInput: 0.0000005, audioCached: 0.00000005,
59
+ },
60
+ "gemini-2.5-pro": {
61
+ input: 0.00000125, output: 0.00001, cached: 0.000000125,
62
+ tierOver: {
63
+ promptTokens: 200000,
64
+ input: 0.0000025, output: 0.000015, cached: 0.00000025,
65
+ },
66
+ },
67
+ "gemini-2.5-flash": {
68
+ input: 0.0000003, output: 0.0000025, cached: 0.00000003,
69
+ audioInput: 0.000001, audioCached: 0.0000001,
70
+ },
71
+ "gemini-2.5-flash-lite": {
72
+ input: 0.0000001, output: 0.0000004, cached: 0.00000001,
73
+ audioInput: 0.0000003, audioCached: 0.00000003,
74
+ },
31
75
  },
32
76
  };
77
+
78
+ /**
79
+ * Resolve a entrada de preço de um modelo Gemini.
80
+ *
81
+ * A API devolve `modelVersion` que muitas vezes carrega sufixo (`-preview-…`,
82
+ * data). Exigir igualdade exata deixaria quase toda resposta real sem preço;
83
+ * casar por prefixo sem avisar seria afirmar que preview custa o mesmo que GA,
84
+ * o que não é garantido. Então: exato é exato, prefixo é marcado `approx`, e o
85
+ * que não casa não recebe preço de outro modelo.
86
+ *
87
+ * @returns {{ id: string, rates: object, approx: boolean } | null}
88
+ */
89
+ function resolveGeminiModel(model) {
90
+ if (!model) return null;
91
+ const table = PRICING.gemini;
92
+ if (table[model]) return { id: model, rates: table[model], approx: false };
93
+
94
+ // Prefixo mais longo primeiro: "gemini-3.5-flash-lite" tem que ganhar de
95
+ // "gemini-3.5-flash" para "gemini-3.5-flash-lite-preview-01".
96
+ const match = Object.keys(table)
97
+ .filter(id => model.startsWith(id))
98
+ .sort((a, b) => b.length - a.length)[0];
99
+
100
+ return match ? { id: match, rates: table[match], approx: true } : null;
101
+ }
102
+
103
+ /**
104
+ * Calcula o custo de uma chamada Gemini a partir do `usageMetadata` da resposta.
105
+ *
106
+ * Nomes de campo conferidos nos tipos do @google/genai@2.15.0 — a doc pública
107
+ * mostra um formato snake_case (`total_input_tokens`) que é de outra superfície
108
+ * da API; o `generateContent` devolve camelCase.
109
+ *
110
+ * @param {string} model — `modelVersion` da resposta, ou o pedido na chamada
111
+ * @param {object} usage — `response.usageMetadata`
112
+ * @returns {{
113
+ * costUSD: number|null, inputTokens: number, outputTokens: number,
114
+ * totalTokens: number, cachedTokens: number, thoughtsTokens: number,
115
+ * audioInputTokens: number, pricedAs: string|null, approxPricing: boolean,
116
+ * unpriced: boolean, reason: string|null
117
+ * }}
118
+ */
119
+ export function priceGemini(model, usage = {}) {
120
+ const prompt = usage.promptTokenCount || 0;
121
+ const cached = usage.cachedContentTokenCount || 0;
122
+ const output = usage.candidatesTokenCount || 0;
123
+ const thoughts = usage.thoughtsTokenCount || 0;
124
+ const toolUse = usage.toolUsePromptTokenCount || 0;
125
+
126
+ // `prompt` já inclui `cached` — o restante é que sai a preço cheio.
127
+ // Nunca deixar negativo: se a API mudar a semântica, subfaturar em silêncio
128
+ // é pior do que somar 0.
129
+ const fullPriceInput = Math.max(0, prompt - cached);
130
+
131
+ // Áudio na entrada tem preço próprio em parte dos modelos. O detalhamento
132
+ // por modalidade se refere ao prompt inteiro (cacheado incluso), então o
133
+ // recorte de áudio a preço cheio é proporcional ao que não veio de cache.
134
+ const audioDetail = (usage.promptTokensDetails || [])
135
+ .find(d => d?.modality === "AUDIO")?.tokenCount || 0;
136
+ const audioFullPrice = prompt > 0
137
+ ? Math.round(audioDetail * (fullPriceInput / prompt))
138
+ : 0;
139
+ const audioCached = Math.max(0, audioDetail - audioFullPrice);
140
+
141
+ const base = {
142
+ inputTokens: prompt + toolUse,
143
+ outputTokens: output + thoughts,
144
+ totalTokens: usage.totalTokenCount || (prompt + output + thoughts + toolUse),
145
+ cachedTokens: cached,
146
+ thoughtsTokens: thoughts,
147
+ audioInputTokens: audioDetail,
148
+ };
149
+
150
+ const resolved = resolveGeminiModel(model);
151
+ if (!resolved) {
152
+ return {
153
+ ...base,
154
+ costUSD: null,
155
+ pricedAs: null,
156
+ approxPricing: false,
157
+ unpriced: true,
158
+ reason: `Modelo "${model || "desconhecido"}" não está na tabela de preço do Gemini — tokens registrados, custo não estimado.`,
159
+ };
160
+ }
161
+
162
+ // O 2.5 Pro troca de faixa pelo tamanho do prompt.
163
+ let rates = resolved.rates;
164
+ if (rates.tierOver && prompt > rates.tierOver.promptTokens) {
165
+ rates = { ...rates, ...rates.tierOver };
166
+ }
167
+
168
+ const textFullPrice = Math.max(0, fullPriceInput - audioFullPrice);
169
+ const textCached = Math.max(0, cached - audioCached);
170
+
171
+ const costUSD =
172
+ textFullPrice * rates.input
173
+ + audioFullPrice * (rates.audioInput ?? rates.input)
174
+ + textCached * (rates.cached ?? rates.input)
175
+ + audioCached * (rates.audioCached ?? rates.cached ?? rates.input)
176
+ + toolUse * rates.input
177
+ // Thinking tokens são cobrados como output.
178
+ + (output + thoughts) * rates.output;
179
+
180
+ return {
181
+ ...base,
182
+ costUSD: parseFloat(costUSD.toFixed(8)),
183
+ pricedAs: resolved.id,
184
+ approxPricing: resolved.approx,
185
+ unpriced: false,
186
+ reason: resolved.approx
187
+ ? `Preço do modelo base "${resolved.id}" aplicado a "${model}" — variante não listada na tabela oficial.`
188
+ : null,
189
+ };
190
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * Teste do wrapAzure contra as classes REAIS do @azure/storage-blob e
3
+ * @azure/cosmos — não contra fakes escritas à mão.
4
+ *
5
+ * Por que existe: os testes de `test.js` usam classes falsas cujo formato foi
6
+ * desenhado pra casar com a implementação, então não têm como reprovar uma
7
+ * suposição errada sobre o SDK de verdade (nome de construtor, método em
8
+ * prototype vs. own property, objeto congelado, fábrica cacheada). Este
9
+ * arquivo exercita a cadeia real.
10
+ *
11
+ * Não faz chamada de rede: usa credencial sintética e só verifica a estrutura
12
+ * (o que sobrevive à mutação in-place). Calibração de PREÇO contra fatura real
13
+ * continua pendente — ver README.
14
+ *
15
+ * Execute: node src/test-azure-real.js
16
+ */
17
+ import assert from "node:assert";
18
+ import { BlobServiceClient } from "@azure/storage-blob";
19
+ import { CosmosClient } from "@azure/cosmos";
20
+ import { CloudBrokerCloud } from "./cloud-wrapper.js";
21
+ import {
22
+ AZURE_BLOB_OPS,
23
+ AZURE_BLOB_ITERATOR_OPS,
24
+ AZURE_COSMOS_ITEMS_OPS,
25
+ AZURE_COSMOS_ITEMS_ITERATOR_OPS,
26
+ } from "./pricing-cloud.js";
27
+
28
+ let ok = 0, fail = 0;
29
+ const t = (name, fn) => {
30
+ try { fn(); console.log(`✅ ${name}`); ok++; }
31
+ catch (e) { console.error(`❌ ${name}\n ${e.message}`); fail++; }
32
+ };
33
+
34
+ // Credenciais sintéticas — nenhuma chamada de rede é feita neste arquivo.
35
+ const BLOB_CONN = "DefaultEndpointsProtocol=https;AccountName=cbtest;AccountKey=dGVzdA==;EndpointSuffix=core.windows.net";
36
+ const COSMOS_CONN = "AccountEndpoint=https://cbtest.documents.azure.com:443/;AccountKey=dGVzdA==;";
37
+
38
+ console.log("🧪 wrapAzure contra o SDK real da Azure (sem rede)\n");
39
+
40
+ // ── Blob ─────────────────────────────────────────────────────────────────────
41
+ const rawBlob = BlobServiceClient.fromConnectionString(BLOB_CONN);
42
+
43
+ t("nomes de construtor reais batem com a tabela de detecção", () => {
44
+ assert.strictEqual(rawBlob.constructor.name, "BlobServiceClient");
45
+ assert.strictEqual(rawBlob.getContainerClient("c").constructor.name, "ContainerClient");
46
+ assert.strictEqual(rawBlob.getContainerClient("c").getBlockBlobClient("b").constructor.name, "BlockBlobClient");
47
+ });
48
+
49
+ const blobSvc = CloudBrokerCloud.wrapAzure(rawBlob, { apiKey: "cb-test-key" });
50
+
51
+ t("wrapAzure reconhece o BlobServiceClient real (não caiu no no-op)", () => {
52
+ assert.strictEqual(blobSvc.__cbWrapped, true, "client real deveria ter sido embrulhado");
53
+ });
54
+
55
+ t("cadeia de fábricas real sobrevive à mutação in-place", () => {
56
+ const container = blobSvc.getContainerClient("docs");
57
+ assert.strictEqual(container.__cbWrapped, true, "ContainerClient devolvido deve vir embrulhado");
58
+ const blob = container.getBlockBlobClient("nf.pdf");
59
+ assert.strictEqual(blob.__cbWrapped, true, "BlockBlobClient devolvido deve vir embrulhado");
60
+ });
61
+
62
+ t("todo método precificado existe em algum client real (nenhuma entrada morta)", () => {
63
+ const container = rawBlob.getContainerClient("docs");
64
+ // appendBlock só existe em AppendBlobClient e uploadPages só em PageBlobClient —
65
+ // achado real deste teste: a tabela precificava os dois, mas checar só o
66
+ // BlockBlobClient dava falso negativo.
67
+ const clients = [
68
+ rawBlob, container,
69
+ container.getBlockBlobClient("nf.pdf"),
70
+ container.getAppendBlobClient("log.txt"),
71
+ container.getPageBlobClient("disk.vhd"),
72
+ ];
73
+ const ausentes = Object.keys(AZURE_BLOB_OPS).filter(
74
+ op => !clients.some(c => typeof c[op] === "function"),
75
+ );
76
+ assert.strictEqual(ausentes.length, 0, `métodos precificados que não existem no SDK: ${ausentes.join(", ")}`);
77
+ });
78
+
79
+ t("AppendBlobClient e PageBlobClient reais também vêm embrulhados", () => {
80
+ const container = blobSvc.getContainerClient("docs");
81
+ assert.strictEqual(container.getAppendBlobClient("log.txt").__cbWrapped, true);
82
+ assert.strictEqual(container.getPageBlobClient("disk.vhd").__cbWrapped, true);
83
+ });
84
+
85
+ t("métodos de list precificados existem mesmo no SDK real", () => {
86
+ const container = rawBlob.getContainerClient("docs");
87
+ const ausentes = AZURE_BLOB_ITERATOR_OPS.filter(
88
+ op => typeof container[op] !== "function" && typeof rawBlob[op] !== "function",
89
+ );
90
+ assert.strictEqual(ausentes.length, 0, `list precificado que não existe: ${ausentes.join(", ")}`);
91
+ });
92
+
93
+ t("embrulhar não quebra métodos não rastreados do client real", () => {
94
+ const container = blobSvc.getContainerClient("docs");
95
+ // getBlobClient/url são API pública que o cliente usa e não rastreamos
96
+ assert.strictEqual(typeof container.url, "string");
97
+ assert.strictEqual(typeof container.getBlobClient("x").url, "string");
98
+ });
99
+
100
+ t("iterator real de listBlobsFlat é lazy (nenhuma rede na criação)", () => {
101
+ const it = blobSvc.getContainerClient("docs").listBlobsFlat();
102
+ // Se fosse eager, isto já teria tentado resolver DNS/HTTP e lançado.
103
+ assert.strictEqual(typeof it.next, "function");
104
+ assert.strictEqual(typeof it.byPage, "function");
105
+ });
106
+
107
+ // ── Cosmos ───────────────────────────────────────────────────────────────────
108
+ const rawCosmos = new CosmosClient(COSMOS_CONN);
109
+
110
+ t("nomes de construtor reais do Cosmos batem", () => {
111
+ assert.strictEqual(rawCosmos.constructor.name, "CosmosClient");
112
+ assert.strictEqual(rawCosmos.database("d").constructor.name, "Database");
113
+ assert.strictEqual(rawCosmos.database("d").container("c").constructor.name, "Container");
114
+ assert.strictEqual(rawCosmos.database("d").container("c").item("i", "pk").constructor.name, "Item");
115
+ });
116
+
117
+ t("Database e Container REAIS têm read/delete (motivo de discriminar o nó)", () => {
118
+ const db = rawCosmos.database("d");
119
+ const ct = db.container("c");
120
+ assert.strictEqual(typeof db.read, "function", "Database.read existe — não pode virar item.read");
121
+ assert.strictEqual(typeof ct.read, "function", "Container.read existe — não pode virar item.read");
122
+ assert.strictEqual(typeof ct.delete, "function");
123
+ });
124
+
125
+ const cosmos = CloudBrokerCloud.wrapAzure(rawCosmos, { apiKey: "cb-test-key" });
126
+
127
+ t("wrapAzure reconhece o CosmosClient real", () => {
128
+ assert.strictEqual(cosmos.__cbWrapped, true);
129
+ });
130
+
131
+ t("cadeia real database → container → item vem embrulhada", () => {
132
+ const db = cosmos.database("loja");
133
+ assert.strictEqual(db.__cbWrapped, true);
134
+ const ct = db.container("pedidos");
135
+ assert.strictEqual(ct.__cbWrapped, true);
136
+ assert.strictEqual(ct.item("1", "pk").__cbWrapped, true);
137
+ });
138
+
139
+ t("container.items real é a mesma referência (getter cacheado) e foi embrulhado", () => {
140
+ const ct = cosmos.database("loja").container("pedidos");
141
+ assert.strictEqual(ct.items, ct.items, "items deve ser estável entre acessos");
142
+ assert.strictEqual(ct.items.__cbWrapped, true);
143
+ });
144
+
145
+ t("métodos de items que a tabela rastreia existem mesmo no SDK real", () => {
146
+ const items = rawCosmos.database("d").container("c").items;
147
+ const esperados = [...Object.keys(AZURE_COSMOS_ITEMS_OPS), ...Object.keys(AZURE_COSMOS_ITEMS_ITERATOR_OPS)];
148
+ const ausentes = esperados.filter(op => typeof items[op] !== "function");
149
+ assert.strictEqual(ausentes.length, 0, `métodos rastreados que não existem: ${ausentes.join(", ")}`);
150
+ });
151
+
152
+ t("query() real devolve QueryIterator com fetchAll/fetchNext", () => {
153
+ const it = cosmos.database("loja").container("pedidos").items.query("SELECT * FROM c");
154
+ assert.strictEqual(typeof it.fetchAll, "function");
155
+ assert.strictEqual(typeof it.fetchNext, "function");
156
+ });
157
+
158
+ t("segundo wrapAzure no mesmo client não re-embrulha", () => {
159
+ const warns = [];
160
+ const orig = console.warn;
161
+ console.warn = (...a) => warns.push(a.join(" "));
162
+ const again = CloudBrokerCloud.wrapAzure(cosmos, { apiKey: "cb-test-key" });
163
+ console.warn = orig;
164
+ assert.strictEqual(again, cosmos);
165
+ assert.ok(warns.some(w => w.includes("já foi embrulhado")), "deve avisar sobre double-wrap");
166
+ });
167
+
168
+ console.log(`\n${fail === 0 ? "🎉 TODOS PASSARAM" : "💥 FALHAS"}: ${ok} ok, ${fail} fail`);
169
+ process.exitCode = fail === 0 ? 0 : 1;