cloudbroker-sdk 0.5.0 → 0.6.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.
- package/.github/workflows/publish.yml +18 -0
- package/README.md +83 -5
- package/docs/COST_PER_FEATURE.md +296 -0
- package/example-checkout-app/.env.example +13 -0
- package/example-checkout-app/README.md +225 -0
- package/example-checkout-app/package.json +28 -0
- package/example-checkout-app/server.js +209 -0
- package/example-checkout-app/test/e2e.js +97 -0
- package/package.json +13 -4
- package/src/client.js +17 -6
- package/src/cloud-wrapper.js +531 -5
- package/src/pricing-cloud.js +165 -10
- package/src/test-azure-real.js +169 -0
- package/src/test.js +264 -0
package/src/pricing-cloud.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
*
|
|
45
|
-
* com precisão)
|
|
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.
|
|
49
|
-
* @param {string} p.
|
|
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
|
|
52
|
-
* @param {
|
|
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
|
|
56
|
-
|
|
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
|
+
}
|
|
@@ -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;
|
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.");
|