@revenium/claude-code-metering 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/.env.example +15 -0
  2. package/.eslintrc.js +24 -0
  3. package/.github/workflows/branch-bypass-alert.yml +68 -0
  4. package/CODE_OF_CONDUCT.md +57 -0
  5. package/CONTRIBUTING.md +73 -0
  6. package/README.md +57 -3
  7. package/SECURITY.md +46 -0
  8. package/dist/cli/commands/setup.js +3 -1
  9. package/dist/cli/commands/setup.js.map +1 -1
  10. package/dist/core/api/client.d.ts.map +1 -1
  11. package/dist/core/api/client.js +4 -1
  12. package/dist/core/api/client.js.map +1 -1
  13. package/dist/core/tool-context.d.ts +6 -0
  14. package/dist/core/tool-context.d.ts.map +1 -0
  15. package/dist/core/tool-context.js +21 -0
  16. package/dist/core/tool-context.js.map +1 -0
  17. package/dist/core/tool-tracker.d.ts +4 -0
  18. package/dist/core/tool-tracker.d.ts.map +1 -0
  19. package/dist/core/tool-tracker.js +156 -0
  20. package/dist/core/tool-tracker.js.map +1 -0
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.d.ts.map +1 -1
  23. package/dist/index.js +2 -0
  24. package/dist/index.js.map +1 -1
  25. package/dist/types/index.d.ts +1 -0
  26. package/dist/types/index.d.ts.map +1 -1
  27. package/dist/types/index.js +15 -0
  28. package/dist/types/index.js.map +1 -1
  29. package/dist/types/tool-metering.d.ts +36 -0
  30. package/dist/types/tool-metering.d.ts.map +1 -0
  31. package/dist/types/tool-metering.js +3 -0
  32. package/dist/types/tool-metering.js.map +1 -0
  33. package/docs/research/settings-json-telemetry-findings.md +171 -0
  34. package/examples/README.md +114 -0
  35. package/examples/validation/validate-installation.sh +212 -0
  36. package/package.json +4 -10
  37. package/public-allowlist-node.txt +7 -0
  38. package/src/cli/commands/backfill.ts +865 -0
  39. package/src/cli/commands/setup.ts +254 -0
  40. package/src/cli/commands/status.ts +108 -0
  41. package/src/cli/commands/test.ts +91 -0
  42. package/src/cli/index.ts +103 -0
  43. package/src/core/api/client.ts +194 -0
  44. package/src/core/config/loader.ts +217 -0
  45. package/src/core/config/validator.ts +142 -0
  46. package/src/core/config/writer.ts +212 -0
  47. package/src/core/shell/detector.ts +92 -0
  48. package/src/core/shell/profile-updater.ts +131 -0
  49. package/src/core/tool-context.ts +23 -0
  50. package/src/core/tool-tracker.ts +204 -0
  51. package/src/index.ts +12 -0
  52. package/src/types/index.ts +110 -0
  53. package/src/types/tool-metering.ts +38 -0
  54. package/src/utils/constants.ts +80 -0
  55. package/src/utils/hashing.ts +35 -0
  56. package/src/utils/masking.ts +32 -0
  57. package/tests/integration/cli-commands.test.ts +158 -0
  58. package/tests/unit/backfill-command.test.ts +366 -0
  59. package/tests/unit/backfill-helpers.test.ts +397 -0
  60. package/tests/unit/backfill-parse.test.ts +276 -0
  61. package/tests/unit/backfill-stream.test.ts +147 -0
  62. package/tests/unit/backfill.test.ts +344 -0
  63. package/tests/unit/cli-index.test.ts +193 -0
  64. package/tests/unit/client.test.ts +195 -0
  65. package/tests/unit/detector.test.ts +247 -0
  66. package/tests/unit/hashing.test.ts +121 -0
  67. package/tests/unit/loader.test.ts +272 -0
  68. package/tests/unit/masking.test.ts +46 -0
  69. package/tests/unit/profile-updater.test.ts +146 -0
  70. package/tests/unit/setup.test.ts +557 -0
  71. package/tests/unit/status.test.ts +149 -0
  72. package/tests/unit/test.test.ts +165 -0
  73. package/tests/unit/validator.test.ts +211 -0
  74. package/tests/unit/writer.test.ts +176 -0
  75. package/tsconfig.json +20 -0
@@ -0,0 +1,195 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ createTestPayload,
4
+ generateTestSessionId,
5
+ sendOtlpLogs,
6
+ checkEndpointHealth,
7
+ } from "../../src/core/api/client.js";
8
+
9
+ global.fetch = vi.fn();
10
+
11
+ describe("generateTestSessionId", () => {
12
+ it("should generate unique session IDs", () => {
13
+ const id1 = generateTestSessionId();
14
+ const id2 = generateTestSessionId();
15
+ expect(id1).not.toBe(id2);
16
+ });
17
+
18
+ it("should start with test- prefix", () => {
19
+ const id = generateTestSessionId();
20
+ expect(id.startsWith("test-")).toBe(true);
21
+ });
22
+ });
23
+
24
+ describe("createTestPayload", () => {
25
+ it("should create valid OTLP payload structure", () => {
26
+ const sessionId = "test-session-123";
27
+ const payload = createTestPayload(sessionId);
28
+
29
+ expect(payload.resourceLogs).toHaveLength(1);
30
+ expect(payload.resourceLogs[0].scopeLogs).toHaveLength(1);
31
+ expect(payload.resourceLogs[0].scopeLogs[0].logRecords).toHaveLength(1);
32
+ });
33
+
34
+ it("should include correct session ID in payload", () => {
35
+ const sessionId = "test-session-456";
36
+ const payload = createTestPayload(sessionId);
37
+
38
+ const attrs = payload.resourceLogs[0].scopeLogs[0].logRecords[0].attributes;
39
+ const sessionAttr = attrs.find((a) => a.key === "session.id");
40
+ expect(sessionAttr?.value.stringValue).toBe(sessionId);
41
+ });
42
+
43
+ it("should include claude_code.api_request body", () => {
44
+ const payload = createTestPayload("test");
45
+ const body = payload.resourceLogs[0].scopeLogs[0].logRecords[0].body;
46
+ expect(body.stringValue).toBe("claude_code.api_request");
47
+ });
48
+
49
+ it("should include all required attributes", () => {
50
+ const payload = createTestPayload("test");
51
+ const attrs = payload.resourceLogs[0].scopeLogs[0].logRecords[0].attributes;
52
+ const attrKeys = attrs.map((a) => a.key);
53
+
54
+ expect(attrKeys).toContain("session.id");
55
+ expect(attrKeys).toContain("model");
56
+ expect(attrKeys).toContain("input_tokens");
57
+ expect(attrKeys).toContain("output_tokens");
58
+ expect(attrKeys).toContain("cost_usd");
59
+ expect(attrKeys).toContain("duration_ms");
60
+ });
61
+ });
62
+
63
+ describe("sendOtlpLogs", () => {
64
+ beforeEach(() => {
65
+ vi.clearAllMocks();
66
+ });
67
+
68
+ afterEach(() => {
69
+ vi.restoreAllMocks();
70
+ });
71
+
72
+ it("should send POST request to correct endpoint", async () => {
73
+ const mockResponse = {
74
+ ok: true,
75
+ status: 200,
76
+ json: vi.fn().mockResolvedValue({ processedEvents: 1 }),
77
+ };
78
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
79
+
80
+ const payload = createTestPayload("test-session");
81
+ await sendOtlpLogs("https://api.revenium.ai", "hak_test123", payload);
82
+
83
+ expect(global.fetch).toHaveBeenCalledWith(
84
+ "https://api.revenium.ai/meter/v2/otlp/v1/logs",
85
+ expect.objectContaining({
86
+ method: "POST",
87
+ headers: expect.objectContaining({
88
+ "Content-Type": "application/json",
89
+ "x-api-key": "hak_test123",
90
+ }),
91
+ }),
92
+ );
93
+ });
94
+
95
+ it("should throw error on non-ok response", async () => {
96
+ const mockResponse = {
97
+ ok: false,
98
+ status: 401,
99
+ statusText: "Unauthorized",
100
+ text: vi.fn().mockResolvedValue("Invalid API key"),
101
+ };
102
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
103
+
104
+ const payload = createTestPayload("test-session");
105
+
106
+ await expect(
107
+ sendOtlpLogs("https://api.revenium.ai", "hak_invalid", payload),
108
+ ).rejects.toThrow("OTLP request failed: 401 Unauthorized");
109
+ });
110
+
111
+ it("should throw error on network failure", async () => {
112
+ vi.mocked(global.fetch).mockRejectedValue(new Error("Network error"));
113
+
114
+ const payload = createTestPayload("test-session");
115
+
116
+ await expect(
117
+ sendOtlpLogs("https://api.revenium.ai", "hak_test123", payload),
118
+ ).rejects.toThrow("Network error");
119
+ });
120
+ });
121
+
122
+ describe("checkEndpointHealth", () => {
123
+ beforeEach(() => {
124
+ vi.clearAllMocks();
125
+ });
126
+
127
+ afterEach(() => {
128
+ vi.restoreAllMocks();
129
+ });
130
+
131
+ it("should return healthy status on 200 response", async () => {
132
+ const mockResponse = {
133
+ ok: true,
134
+ status: 200,
135
+ json: vi.fn().mockResolvedValue({ processedEvents: 1 }),
136
+ };
137
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
138
+
139
+ const result = await checkEndpointHealth(
140
+ "https://api.revenium.ai",
141
+ "hak_test123",
142
+ );
143
+
144
+ expect(result.healthy).toBe(true);
145
+ expect(result.statusCode).toBe(200);
146
+ expect(result.message).toContain("Endpoint healthy");
147
+ });
148
+
149
+ it("should return unhealthy status on 401 response", async () => {
150
+ const mockResponse = {
151
+ ok: false,
152
+ status: 401,
153
+ statusText: "Unauthorized",
154
+ text: vi.fn().mockResolvedValue("Invalid API key"),
155
+ };
156
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
157
+
158
+ const result = await checkEndpointHealth(
159
+ "https://api.revenium.ai",
160
+ "hak_invalid",
161
+ );
162
+
163
+ expect(result.healthy).toBe(false);
164
+ expect(result.statusCode).toBe(401);
165
+ expect(result.message).toContain("401");
166
+ });
167
+
168
+ it("should return unhealthy status on network error", async () => {
169
+ vi.mocked(global.fetch).mockRejectedValue(new Error("Network timeout"));
170
+
171
+ const result = await checkEndpointHealth(
172
+ "https://api.revenium.ai",
173
+ "hak_test123",
174
+ );
175
+
176
+ expect(result.healthy).toBe(false);
177
+ expect(result.message).toContain("Network timeout");
178
+ });
179
+
180
+ it("should measure latency", async () => {
181
+ const mockResponse = {
182
+ ok: true,
183
+ status: 200,
184
+ json: vi.fn().mockResolvedValue({ id: "test-123" }),
185
+ };
186
+ vi.mocked(global.fetch).mockResolvedValue(mockResponse as any);
187
+
188
+ const result = await checkEndpointHealth(
189
+ "https://api.revenium.ai",
190
+ "hak_test123",
191
+ );
192
+
193
+ expect(result.latencyMs).toBeGreaterThanOrEqual(0);
194
+ });
195
+ });
@@ -0,0 +1,247 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import {
3
+ validateConfigPath,
4
+ getSourceCommand,
5
+ detectShell,
6
+ getProfilePath,
7
+ } from "../../src/core/shell/detector.js";
8
+ import { existsSync } from "node:fs";
9
+ import { homedir } from "node:os";
10
+
11
+ vi.mock("node:fs");
12
+ vi.mock("node:os");
13
+
14
+ describe("validateConfigPath", () => {
15
+ it("should accept valid path with alphanumeric and safe characters", () => {
16
+ expect(() =>
17
+ validateConfigPath("/home/user/.claude/revenium.env"),
18
+ ).not.toThrow();
19
+ expect(() => validateConfigPath("~/.claude/revenium.env")).not.toThrow();
20
+ expect(() => validateConfigPath("/opt/config-file_v1.0.env")).not.toThrow();
21
+ });
22
+
23
+ it("should reject path with semicolon", () => {
24
+ expect(() => validateConfigPath("/path;rm -rf /")).toThrow(
25
+ /Invalid config path/,
26
+ );
27
+ });
28
+
29
+ it("should reject path with backticks", () => {
30
+ expect(() => validateConfigPath("/path/`whoami`/file")).toThrow(
31
+ /Invalid config path/,
32
+ );
33
+ });
34
+
35
+ it("should reject path with dollar sign", () => {
36
+ expect(() => validateConfigPath("/path/$(whoami)/file")).toThrow(
37
+ /Invalid config path/,
38
+ );
39
+ });
40
+
41
+ it("should reject path with pipe", () => {
42
+ expect(() => validateConfigPath("/path/file | cat")).toThrow(
43
+ /Invalid config path/,
44
+ );
45
+ });
46
+
47
+ it("should reject path with ampersand", () => {
48
+ expect(() => validateConfigPath("/path/file & echo")).toThrow(
49
+ /Invalid config path/,
50
+ );
51
+ });
52
+
53
+ it("should reject path with newline", () => {
54
+ expect(() => validateConfigPath("/path/file\nrm -rf /")).toThrow(
55
+ /Invalid config path/,
56
+ );
57
+ });
58
+
59
+ it("should accept path with spaces", () => {
60
+ expect(() => validateConfigPath("/path with spaces/file")).not.toThrow();
61
+ expect(() =>
62
+ validateConfigPath("/Users/John Doe/.claude/revenium.env"),
63
+ ).not.toThrow();
64
+ });
65
+
66
+ it("should accept path with tilde", () => {
67
+ expect(() => validateConfigPath("~/config.env")).not.toThrow();
68
+ });
69
+
70
+ it("should accept path with dots", () => {
71
+ expect(() =>
72
+ validateConfigPath("/home/user/.config/file.env"),
73
+ ).not.toThrow();
74
+ });
75
+
76
+ it("should accept path with hyphens and underscores", () => {
77
+ expect(() =>
78
+ validateConfigPath("/opt/my-app_config/file-v1_0.env"),
79
+ ).not.toThrow();
80
+ });
81
+ });
82
+
83
+ describe("getSourceCommand", () => {
84
+ const validPath = "/home/user/.claude/revenium.env";
85
+
86
+ it("should generate valid bash source command", () => {
87
+ const command = getSourceCommand("bash", validPath);
88
+ expect(command).toContain('if [ -f "');
89
+ expect(command).toContain(validPath);
90
+ expect(command).toContain('source "');
91
+ expect(command).toContain("# Source Revenium Claude Code metering config");
92
+ });
93
+
94
+ it("should generate valid zsh source command", () => {
95
+ const command = getSourceCommand("zsh", validPath);
96
+ expect(command).toContain('if [ -f "');
97
+ expect(command).toContain(validPath);
98
+ expect(command).toContain('source "');
99
+ });
100
+
101
+ it("should generate valid fish source command", () => {
102
+ const command = getSourceCommand("fish", validPath);
103
+ expect(command).toContain('if test -f "');
104
+ expect(command).toContain(validPath);
105
+ expect(command).toContain("export (cat");
106
+ expect(command).toContain("# Source Revenium Claude Code metering config");
107
+ });
108
+
109
+ it("should properly quote path in bash command", () => {
110
+ const command = getSourceCommand("bash", validPath);
111
+ expect(command).toMatch(/"[^"]+"/);
112
+ });
113
+
114
+ it("should properly quote path in fish command", () => {
115
+ const command = getSourceCommand("fish", validPath);
116
+ expect(command).toMatch(/"[^"]+"/);
117
+ });
118
+
119
+ it("should throw error for invalid path with semicolon", () => {
120
+ expect(() => getSourceCommand("bash", "/path;rm -rf /")).toThrow(
121
+ /Invalid config path/,
122
+ );
123
+ });
124
+
125
+ it("should throw error for invalid path with backticks", () => {
126
+ expect(() => getSourceCommand("fish", "/path/`whoami`")).toThrow(
127
+ /Invalid config path/,
128
+ );
129
+ });
130
+
131
+ it("should throw error for invalid path with dollar sign", () => {
132
+ expect(() => getSourceCommand("zsh", "/path/$(cmd)")).toThrow(
133
+ /Invalid config path/,
134
+ );
135
+ });
136
+
137
+ it("should handle unknown shell type with bash syntax", () => {
138
+ const command = getSourceCommand("unknown", validPath);
139
+ expect(command).toContain('if [ -f "');
140
+ expect(command).toContain('source "');
141
+ });
142
+ });
143
+
144
+ describe("detectShell", () => {
145
+ const originalEnv = process.env;
146
+
147
+ beforeEach(() => {
148
+ vi.clearAllMocks();
149
+ process.env = { ...originalEnv };
150
+ vi.mocked(homedir).mockReturnValue("/home/user");
151
+ });
152
+
153
+ afterEach(() => {
154
+ process.env = originalEnv;
155
+ vi.restoreAllMocks();
156
+ });
157
+
158
+ it("should detect zsh from SHELL environment variable", () => {
159
+ process.env.SHELL = "/bin/zsh";
160
+ const shell = detectShell();
161
+ expect(shell).toBe("zsh");
162
+ });
163
+
164
+ it("should detect bash from SHELL environment variable", () => {
165
+ process.env.SHELL = "/bin/bash";
166
+ const shell = detectShell();
167
+ expect(shell).toBe("bash");
168
+ });
169
+
170
+ it("should detect fish from SHELL environment variable", () => {
171
+ process.env.SHELL = "/usr/bin/fish";
172
+ const shell = detectShell();
173
+ expect(shell).toBe("fish");
174
+ });
175
+
176
+ it("should fallback to detecting zsh from .zshrc file", () => {
177
+ process.env.SHELL = "";
178
+ vi.mocked(existsSync).mockImplementation(
179
+ (path) => path === "/home/user/.zshrc",
180
+ );
181
+ const shell = detectShell();
182
+ expect(shell).toBe("zsh");
183
+ });
184
+
185
+ it("should fallback to detecting fish from config.fish file", () => {
186
+ process.env.SHELL = "";
187
+ vi.mocked(existsSync).mockImplementation(
188
+ (path) => path === "/home/user/.config/fish/config.fish",
189
+ );
190
+ const shell = detectShell();
191
+ expect(shell).toBe("fish");
192
+ });
193
+
194
+ it("should fallback to detecting bash from .bashrc file", () => {
195
+ process.env.SHELL = "";
196
+ vi.mocked(existsSync).mockImplementation(
197
+ (path) => path === "/home/user/.bashrc",
198
+ );
199
+ const shell = detectShell();
200
+ expect(shell).toBe("bash");
201
+ });
202
+
203
+ it("should return unknown when no shell is detected", () => {
204
+ process.env.SHELL = "";
205
+ vi.mocked(existsSync).mockReturnValue(false);
206
+ const shell = detectShell();
207
+ expect(shell).toBe("unknown");
208
+ });
209
+ });
210
+
211
+ describe("getProfilePath", () => {
212
+ beforeEach(() => {
213
+ vi.clearAllMocks();
214
+ vi.mocked(homedir).mockReturnValue("/home/user");
215
+ });
216
+
217
+ afterEach(() => {
218
+ vi.restoreAllMocks();
219
+ });
220
+
221
+ it("should return .zshrc path for zsh", () => {
222
+ const path = getProfilePath("zsh");
223
+ expect(path).toBe("/home/user/.zshrc");
224
+ });
225
+
226
+ it("should return .bashrc path for bash when it exists", () => {
227
+ vi.mocked(existsSync).mockReturnValue(true);
228
+ const path = getProfilePath("bash");
229
+ expect(path).toBe("/home/user/.bashrc");
230
+ });
231
+
232
+ it("should return .bash_profile path for bash when .bashrc does not exist", () => {
233
+ vi.mocked(existsSync).mockReturnValue(false);
234
+ const path = getProfilePath("bash");
235
+ expect(path).toBe("/home/user/.bash_profile");
236
+ });
237
+
238
+ it("should return config.fish path for fish", () => {
239
+ const path = getProfilePath("fish");
240
+ expect(path).toBe("/home/user/.config/fish/config.fish");
241
+ });
242
+
243
+ it("should return null for unknown shell", () => {
244
+ const path = getProfilePath("unknown");
245
+ expect(path).toBeNull();
246
+ });
247
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { generateTransactionId, type TransactionIdComponents } from '../../src/utils/hashing.js';
3
+
4
+ describe('generateTransactionId', () => {
5
+ const baseComponents: TransactionIdComponents = {
6
+ sessionId: '5345477c-26de-46ed-8eb1-d1deea0ee61f',
7
+ timestamp: '2026-01-13T15:15:09.790Z',
8
+ model: 'claude-opus-4-5-20251101',
9
+ inputTokens: 100,
10
+ outputTokens: 50,
11
+ cacheReadTokens: 1000,
12
+ cacheCreationTokens: 500,
13
+ };
14
+
15
+ it('should generate a 32-character hex string', () => {
16
+ const result = generateTransactionId(baseComponents);
17
+ expect(result).toHaveLength(32);
18
+ expect(result).toMatch(/^[0-9a-f]{32}$/);
19
+ });
20
+
21
+ it('should be deterministic (same input produces same output)', () => {
22
+ const result1 = generateTransactionId(baseComponents);
23
+ const result2 = generateTransactionId(baseComponents);
24
+ const result3 = generateTransactionId({ ...baseComponents });
25
+ expect(result1).toBe(result2);
26
+ expect(result1).toBe(result3);
27
+ });
28
+
29
+ it('should produce different outputs for different inputs', () => {
30
+ const result1 = generateTransactionId(baseComponents);
31
+ const result2 = generateTransactionId({
32
+ ...baseComponents,
33
+ inputTokens: 101,
34
+ });
35
+ expect(result1).not.toBe(result2);
36
+ });
37
+
38
+ it('should be sensitive to sessionId changes', () => {
39
+ const result1 = generateTransactionId(baseComponents);
40
+ const result2 = generateTransactionId({
41
+ ...baseComponents,
42
+ sessionId: 'different-session-id',
43
+ });
44
+ expect(result1).not.toBe(result2);
45
+ });
46
+
47
+ it('should be sensitive to timestamp changes', () => {
48
+ const result1 = generateTransactionId(baseComponents);
49
+ const result2 = generateTransactionId({
50
+ ...baseComponents,
51
+ timestamp: '2026-01-13T15:15:09.791Z',
52
+ });
53
+ expect(result1).not.toBe(result2);
54
+ });
55
+
56
+ it('should be sensitive to model changes', () => {
57
+ const result1 = generateTransactionId(baseComponents);
58
+ const result2 = generateTransactionId({
59
+ ...baseComponents,
60
+ model: 'claude-sonnet-4-20250514',
61
+ });
62
+ expect(result1).not.toBe(result2);
63
+ });
64
+
65
+ it('should handle zero token values', () => {
66
+ const zeroTokenComponents: TransactionIdComponents = {
67
+ sessionId: 'test-session',
68
+ timestamp: '2026-01-01T00:00:00.000Z',
69
+ model: 'test-model',
70
+ inputTokens: 0,
71
+ outputTokens: 0,
72
+ cacheReadTokens: 0,
73
+ cacheCreationTokens: 0,
74
+ };
75
+ const result = generateTransactionId(zeroTokenComponents);
76
+ expect(result).toHaveLength(32);
77
+ expect(result).toMatch(/^[0-9a-f]{32}$/);
78
+ });
79
+
80
+ it('should handle large token values', () => {
81
+ const largeTokenComponents: TransactionIdComponents = {
82
+ ...baseComponents,
83
+ inputTokens: 1000000,
84
+ outputTokens: 500000,
85
+ cacheReadTokens: 10000000,
86
+ cacheCreationTokens: 5000000,
87
+ };
88
+ const result = generateTransactionId(largeTokenComponents);
89
+ expect(result).toHaveLength(32);
90
+ expect(result).toMatch(/^[0-9a-f]{32}$/);
91
+ });
92
+
93
+ /**
94
+ * CROSS-IMPLEMENTATION VERIFICATION TEST
95
+ *
96
+ * This test vector must produce the same hash in both:
97
+ * - TypeScript (this implementation)
98
+ * - Kotlin (ClaudeCodeMapper.kt in the backend)
99
+ *
100
+ * If you change the hash formula, update both implementations
101
+ * and regenerate this expected value.
102
+ */
103
+ it('should produce known hash for test vector (cross-implementation verification)', () => {
104
+ const testVector: TransactionIdComponents = {
105
+ sessionId: '5345477c-26de-46ed-8eb1-d1deea0ee61f',
106
+ timestamp: '2026-01-13T15:15:09.790Z',
107
+ model: 'claude-opus-4-5-20251101',
108
+ inputTokens: 100,
109
+ outputTokens: 50,
110
+ cacheReadTokens: 1000,
111
+ cacheCreationTokens: 500,
112
+ };
113
+
114
+ const result = generateTransactionId(testVector);
115
+
116
+ // This is the expected hash for the above input.
117
+ // The Kotlin implementation must produce the same value.
118
+ // Input string: "5345477c-26de-46ed-8eb1-d1deea0ee61f|2026-01-13T15:15:09.790Z|claude-opus-4-5-20251101|100|50|1000|500"
119
+ expect(result).toBe('a4ae0241320cd35508c022af01424382');
120
+ });
121
+ });