Files
n8n-mcp/tests/unit/telemetry/mutation-tracker.test.ts
Romuald Członkowski 1bbfaabbc2 fix: add structural hash tracking for workflow mutations (#422)
* feat: add structural hashes and success tracking for workflow mutations

Enables cross-referencing workflow_mutations with telemetry_workflows by adding structural hashes (nodeTypes + connections) alongside existing full hashes.

**Database Changes:**
- Added workflow_structure_hash_before/after columns
- Added is_truly_successful computed column
- Created 3 analytics views: successful_mutations, mutation_training_data, mutations_with_workflow_quality
- Created 2 helper functions: get_mutation_success_rate_by_intent(), get_mutation_crossref_stats()

**Code Changes:**
- Updated mutation-tracker.ts to generate both hash types
- Updated mutation-types.ts with new fields
- Auto-converts to snake_case via existing toSnakeCase() function

**Testing:**
- Added 5 new unit tests for structural hash generation
- All 17 tests passing

**Tooling:**
- Created backfill script to populate hashes for existing 1,499 mutations
- Created comprehensive documentation (STRUCTURAL_HASHES.md)

**Impact:**
- Before: 0% cross-reference match rate
- After: Expected 60-70% match rate (post-backfill)
- Unlocks quality impact analysis, training data curation, and mutation pattern insights

Conceived by Romuald Członkowski - www.aiadvisors.pl/en

* fix: correct test operation types for structural hash tests

Fixed TypeScript errors in mutation-tracker tests by adding required
'updates' parameter to updateNode operations. Used 'as any' for test
operations to maintain backward compatibility while tests are updated.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

* chore: remove documentation files from tracking

Removed internal documentation files from version control:
- Telemetry implementation docs
- Implementation roadmap
- Disabled tools analysis docs

These files are for internal reference only.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

* chore: remove telemetry documentation files from tracking

Removed all telemetry analysis and documentation files from root directory.
These files are for internal reference only and should not be in version control.

Files removed:
- TELEMETRY_ANALYSIS*.md
- TELEMETRY_MUTATION_SPEC.md
- TELEMETRY_*_DATASET.md
- VALIDATION_ANALYSIS*.md

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

* chore: bump version to 2.22.18 and update CHANGELOG

Version 2.22.18 adds structural hash tracking for workflow mutations,
enabling cross-referencing with workflow quality data and automated
success detection.

Key changes:
- Added workflowStructureHashBefore/After fields
- Added isTrulySuccessful computed field
- Enhanced mutation tracking with structural hashes
- All tests passing (17/17)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en

* chore: remove migration and documentation files from PR

Removed internal database migration files and documentation from
version control:
- docs/migrations/
- docs/telemetry/

Updated CHANGELOG to remove database migration references.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Conceived by Romuald Członkowski - https://www.aiadvisors.pl/en
2025-11-14 13:57:54 +01:00

818 lines
25 KiB
TypeScript

/**
* Unit tests for MutationTracker - Sanitization and Processing
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { MutationTracker } from '../../../src/telemetry/mutation-tracker';
import { WorkflowMutationData, MutationToolName } from '../../../src/telemetry/mutation-types';
describe('MutationTracker', () => {
let tracker: MutationTracker;
beforeEach(() => {
tracker = new MutationTracker();
tracker.clearRecentMutations();
});
describe('Workflow Sanitization', () => {
it('should remove credentials from workflow level', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test sanitization',
operations: [{ type: 'updateNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [],
connections: {},
credentials: { apiKey: 'secret-key-123' },
sharedWorkflows: ['user1', 'user2'],
ownedBy: { id: 'user1', email: 'user@example.com' }
},
workflowAfter: {
id: 'wf1',
name: 'Test Updated',
nodes: [],
connections: {},
credentials: { apiKey: 'secret-key-456' }
},
mutationSuccess: true,
durationMs: 100
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
expect(result!.workflowBefore).toBeDefined();
expect(result!.workflowBefore.credentials).toBeUndefined();
expect(result!.workflowBefore.sharedWorkflows).toBeUndefined();
expect(result!.workflowBefore.ownedBy).toBeUndefined();
expect(result!.workflowAfter.credentials).toBeUndefined();
});
it('should remove credentials from node level', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test node credentials',
operations: [{ type: 'addNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
credentials: {
httpBasicAuth: {
id: 'cred-123',
name: 'My Auth'
}
},
parameters: {
url: 'https://api.example.com'
}
}
],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
credentials: {
httpBasicAuth: {
id: 'cred-456',
name: 'Updated Auth'
}
},
parameters: {
url: 'https://api.example.com'
}
}
],
connections: {}
},
mutationSuccess: true,
durationMs: 150
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
expect(result!.workflowBefore.nodes[0].credentials).toBeUndefined();
expect(result!.workflowAfter.nodes[0].credentials).toBeUndefined();
});
it('should redact API keys in parameters', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test API key redaction',
operations: [{ type: 'updateNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'OpenAI',
type: 'n8n-nodes-base.openAi',
position: [100, 100],
parameters: {
apiKeyField: 'sk-1234567890abcdef1234567890abcdef',
tokenField: 'Bearer abc123def456',
config: {
passwordField: 'secret-password-123'
}
}
}
],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'OpenAI',
type: 'n8n-nodes-base.openAi',
position: [100, 100],
parameters: {
apiKeyField: 'sk-newkey567890abcdef1234567890abcdef'
}
}
],
connections: {}
},
mutationSuccess: true,
durationMs: 200
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
const params = result!.workflowBefore.nodes[0].parameters;
// Fields with sensitive key names are redacted
expect(params.apiKeyField).toBe('[REDACTED]');
expect(params.tokenField).toBe('[REDACTED]');
expect(params.config.passwordField).toBe('[REDACTED]');
});
it('should redact URLs with authentication', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test URL redaction',
operations: [{ type: 'updateNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {
url: 'https://user:password@api.example.com/endpoint',
webhookUrl: 'http://admin:secret@webhook.example.com'
}
}
],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test',
nodes: [],
connections: {}
},
mutationSuccess: true,
durationMs: 100
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
const params = result!.workflowBefore.nodes[0].parameters;
// URL auth is redacted but path is preserved
expect(params.url).toBe('[REDACTED_URL_WITH_AUTH]/endpoint');
expect(params.webhookUrl).toBe('[REDACTED_URL_WITH_AUTH]');
});
it('should redact long tokens (32+ characters)', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test token redaction',
operations: [{ type: 'updateNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'Slack',
type: 'n8n-nodes-base.slack',
position: [100, 100],
parameters: {
message: 'Token: test-token-1234567890-1234567890123-abcdefghijklmnopqrstuvwx'
}
}
],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test',
nodes: [],
connections: {}
},
mutationSuccess: true,
durationMs: 100
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
const message = result!.workflowBefore.nodes[0].parameters.message;
expect(message).toContain('[REDACTED_TOKEN]');
});
it('should redact OpenAI-style keys', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test OpenAI key redaction',
operations: [{ type: 'updateNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'Code',
type: 'n8n-nodes-base.code',
position: [100, 100],
parameters: {
code: 'const apiKey = "sk-proj-abcd1234efgh5678ijkl9012mnop3456";'
}
}
],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test',
nodes: [],
connections: {}
},
mutationSuccess: true,
durationMs: 100
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
const code = result!.workflowBefore.nodes[0].parameters.code;
// The 32+ char regex runs before OpenAI-specific regex, so it becomes [REDACTED_TOKEN]
expect(code).toContain('[REDACTED_TOKEN]');
});
it('should redact Bearer tokens', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test Bearer token redaction',
operations: [{ type: 'updateNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'HTTP Request',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {
headerParameters: {
parameter: [
{
name: 'Authorization',
value: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c'
}
]
}
}
}
],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test',
nodes: [],
connections: {}
},
mutationSuccess: true,
durationMs: 100
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
const authValue = result!.workflowBefore.nodes[0].parameters.headerParameters.parameter[0].value;
expect(authValue).toBe('Bearer [REDACTED]');
});
it('should preserve workflow structure while sanitizing', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test structure preservation',
operations: [{ type: 'addNode' }],
workflowBefore: {
id: 'wf1',
name: 'My Workflow',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [100, 100],
parameters: {}
},
{
id: 'node2',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [300, 100],
parameters: {
url: 'https://api.example.com',
apiKey: 'secret-key'
}
}
],
connections: {
Start: {
main: [[{ node: 'HTTP', type: 'main', index: 0 }]]
}
},
active: true,
credentials: { apiKey: 'workflow-secret' }
},
workflowAfter: {
id: 'wf1',
name: 'My Workflow',
nodes: [],
connections: {}
},
mutationSuccess: true,
durationMs: 150
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
// Check structure preserved
expect(result!.workflowBefore.id).toBe('wf1');
expect(result!.workflowBefore.name).toBe('My Workflow');
expect(result!.workflowBefore.nodes).toHaveLength(2);
expect(result!.workflowBefore.connections).toBeDefined();
expect(result!.workflowBefore.active).toBe(true);
// Check credentials removed
expect(result!.workflowBefore.credentials).toBeUndefined();
// Check node parameters sanitized
expect(result!.workflowBefore.nodes[1].parameters.apiKey).toBe('[REDACTED]');
// Check connections preserved
expect(result!.workflowBefore.connections.Start).toBeDefined();
});
it('should handle nested objects recursively', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test nested sanitization',
operations: [{ type: 'updateNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'Complex Node',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: {
authentication: {
type: 'oauth2',
// Use 'settings' instead of 'credentials' since 'credentials' is a sensitive key
settings: {
clientId: 'safe-client-id',
clientSecret: 'very-secret-key',
nested: {
apiKeyValue: 'deep-secret-key',
tokenValue: 'nested-token'
}
}
}
}
}
],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test',
nodes: [],
connections: {}
},
mutationSuccess: true,
durationMs: 100
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
const auth = result!.workflowBefore.nodes[0].parameters.authentication;
// The key 'authentication' contains 'auth' which is sensitive, so entire object is redacted
expect(auth).toBe('[REDACTED]');
});
});
describe('Deduplication', () => {
it('should detect and skip duplicate mutations', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'First mutation',
operations: [{ type: 'updateNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test Updated',
nodes: [],
connections: {}
},
mutationSuccess: true,
durationMs: 100
};
// First mutation should succeed
const result1 = await tracker.processMutation(data, 'test-user');
expect(result1).toBeTruthy();
// Exact duplicate should be skipped
const result2 = await tracker.processMutation(data, 'test-user');
expect(result2).toBeNull();
});
it('should allow mutations with different workflows', async () => {
const data1: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'First mutation',
operations: [{ type: 'updateNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test 1',
nodes: [],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test 1 Updated',
nodes: [],
connections: {}
},
mutationSuccess: true,
durationMs: 100
};
const data2: WorkflowMutationData = {
...data1,
workflowBefore: {
id: 'wf2',
name: 'Test 2',
nodes: [],
connections: {}
},
workflowAfter: {
id: 'wf2',
name: 'Test 2 Updated',
nodes: [],
connections: {}
}
};
const result1 = await tracker.processMutation(data1, 'test-user');
const result2 = await tracker.processMutation(data2, 'test-user');
expect(result1).toBeTruthy();
expect(result2).toBeTruthy();
});
});
describe('Structural Hash Generation', () => {
it('should generate structural hashes for both before and after workflows', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test structural hash generation',
operations: [{ type: 'addNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [100, 100],
parameters: {}
}
],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [100, 100],
parameters: {}
},
{
id: 'node2',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [300, 100],
parameters: { url: 'https://api.example.com' }
}
],
connections: {
Start: {
main: [[{ node: 'HTTP', type: 'main', index: 0 }]]
}
}
},
mutationSuccess: true,
durationMs: 100
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
expect(result!.workflowStructureHashBefore).toBeDefined();
expect(result!.workflowStructureHashAfter).toBeDefined();
expect(typeof result!.workflowStructureHashBefore).toBe('string');
expect(typeof result!.workflowStructureHashAfter).toBe('string');
expect(result!.workflowStructureHashBefore!.length).toBe(16);
expect(result!.workflowStructureHashAfter!.length).toBe(16);
});
it('should generate different structural hashes when node types change', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test hash changes with node types',
operations: [{ type: 'addNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [100, 100],
parameters: {}
}
],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test',
nodes: [
{
id: 'node1',
name: 'Start',
type: 'n8n-nodes-base.start',
position: [100, 100],
parameters: {}
},
{
id: 'node2',
name: 'Slack',
type: 'n8n-nodes-base.slack',
position: [300, 100],
parameters: {}
}
],
connections: {}
},
mutationSuccess: true,
durationMs: 100
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
expect(result!.workflowStructureHashBefore).not.toBe(result!.workflowStructureHashAfter);
});
it('should generate same structural hash for workflows with same structure but different parameters', async () => {
const workflow1Before = {
id: 'wf1',
name: 'Test 1',
nodes: [
{
id: 'node1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: { url: 'https://api1.example.com' }
}
],
connections: {}
};
const workflow1After = {
id: 'wf1',
name: 'Test 1 Updated',
nodes: [
{
id: 'node1',
name: 'HTTP',
type: 'n8n-nodes-base.httpRequest',
position: [100, 100],
parameters: { url: 'https://api1-updated.example.com' }
}
],
connections: {}
};
const workflow2Before = {
id: 'wf2',
name: 'Test 2',
nodes: [
{
id: 'node2',
name: 'Different Name',
type: 'n8n-nodes-base.httpRequest',
position: [200, 200],
parameters: { url: 'https://api2.example.com' }
}
],
connections: {}
};
const workflow2After = {
id: 'wf2',
name: 'Test 2 Updated',
nodes: [
{
id: 'node2',
name: 'Different Name',
type: 'n8n-nodes-base.httpRequest',
position: [200, 200],
parameters: { url: 'https://api2-updated.example.com' }
}
],
connections: {}
};
const data1: WorkflowMutationData = {
sessionId: 'test-session-1',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test 1',
operations: [{ type: 'updateNode', nodeId: 'node1', updates: { 'parameters.test': 'value1' } } as any],
workflowBefore: workflow1Before,
workflowAfter: workflow1After,
mutationSuccess: true,
durationMs: 100
};
const data2: WorkflowMutationData = {
sessionId: 'test-session-2',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test 2',
operations: [{ type: 'updateNode', nodeId: 'node2', updates: { 'parameters.test': 'value2' } } as any],
workflowBefore: workflow2Before,
workflowAfter: workflow2After,
mutationSuccess: true,
durationMs: 100
};
const result1 = await tracker.processMutation(data1, 'test-user-1');
const result2 = await tracker.processMutation(data2, 'test-user-2');
expect(result1).toBeTruthy();
expect(result2).toBeTruthy();
// Same structure (same node types, same connection structure) should yield same hash
expect(result1!.workflowStructureHashBefore).toBe(result2!.workflowStructureHashBefore);
});
it('should generate both full hash and structural hash', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test both hash types',
operations: [{ type: 'updateNode' }],
workflowBefore: {
id: 'wf1',
name: 'Test',
nodes: [],
connections: {}
},
workflowAfter: {
id: 'wf1',
name: 'Test Updated',
nodes: [],
connections: {}
},
mutationSuccess: true,
durationMs: 100
};
const result = await tracker.processMutation(data, 'test-user');
expect(result).toBeTruthy();
// Full hashes (includes all workflow data)
expect(result!.workflowHashBefore).toBeDefined();
expect(result!.workflowHashAfter).toBeDefined();
// Structural hashes (nodeTypes + connections only)
expect(result!.workflowStructureHashBefore).toBeDefined();
expect(result!.workflowStructureHashAfter).toBeDefined();
// They should be different since they hash different data
expect(result!.workflowHashBefore).not.toBe(result!.workflowStructureHashBefore);
});
});
describe('Statistics', () => {
it('should track recent mutations count', async () => {
expect(tracker.getRecentMutationsCount()).toBe(0);
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test counting',
operations: [{ type: 'updateNode' }],
workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
workflowAfter: { id: 'wf1', name: 'Test Updated', nodes: [], connections: {} },
mutationSuccess: true,
durationMs: 100
};
await tracker.processMutation(data, 'test-user');
expect(tracker.getRecentMutationsCount()).toBe(1);
// Process another with different workflow
const data2 = { ...data, workflowBefore: { ...data.workflowBefore, id: 'wf2' } };
await tracker.processMutation(data2, 'test-user');
expect(tracker.getRecentMutationsCount()).toBe(2);
});
it('should clear recent mutations', async () => {
const data: WorkflowMutationData = {
sessionId: 'test-session',
toolName: MutationToolName.UPDATE_PARTIAL,
userIntent: 'Test clearing',
operations: [{ type: 'updateNode' }],
workflowBefore: { id: 'wf1', name: 'Test', nodes: [], connections: {} },
workflowAfter: { id: 'wf1', name: 'Test Updated', nodes: [], connections: {} },
mutationSuccess: true,
durationMs: 100
};
await tracker.processMutation(data, 'test-user');
expect(tracker.getRecentMutationsCount()).toBe(1);
tracker.clearRecentMutations();
expect(tracker.getRecentMutationsCount()).toBe(0);
});
});
});