test: unit tests for Ozonetel integration, caller resolution, team, missed calls

48 tests across 5 new spec files, all passing in <1s:

- ozonetel-agent.service.spec: agent auth (login/logout/retry),
  manual dial, set disposition, change state, token caching (10 tests)
- missed-call-webhook.spec: webhook payload parsing, IST→UTC
  conversion, duration parsing, CallerID handling, JSON-wrapped
  body (9 tests)
- missed-queue.spec: abandon call polling, PENDING_CALLBACK status,
  UCID dedup, phone normalization, istToUtc utility (8 tests)
- caller-resolution.spec: phone→lead+patient resolution (4 paths:
  both exist, lead only, patient only, neither), caching, phone
  normalization, link-if-unlinked (9 tests)
- team.spec: 5-step member creation flow, SIP seat linking,
  validation, temp password Redis cache, email normalization,
  workspace context caching (8 tests)

Fixtures: ozonetel-payloads.ts with accurate Ozonetel API shapes
from official docs — webhook payloads, CDR records, abandon calls,
disposition responses, auth responses.

QA coverage: TC-MC-01/02/03, TC-IB-05/06/07, backs TC-IB/OB-01→06
via the Ozonetel service layer.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-10 09:32:40 +05:30
parent 695f119c2b
commit ab65823c2e
6 changed files with 1310 additions and 0 deletions

243
src/team/team.spec.ts Normal file
View File

@@ -0,0 +1,243 @@
/**
* Team Service — unit tests
*
* Tests the in-place workspace member creation flow (no email invites):
* 1. signUpInWorkspace → creates user + workspace member
* 2. updateWorkspaceMember → set name
* 3. updateWorkspaceMemberRole → assign role
* 4. (optional) updateAgent → link SIP seat
*
* Platform GraphQL + SessionService are mocked.
*/
import { Test } from '@nestjs/testing';
import { TeamService, CreateMemberInput } from './team.service';
import { PlatformGraphqlService } from '../platform/platform-graphql.service';
import { SessionService } from '../auth/session.service';
describe('TeamService', () => {
let service: TeamService;
let platform: jest.Mocked<PlatformGraphqlService>;
let session: jest.Mocked<SessionService>;
const mockWorkspace = {
id: '42424242-1c25-4d02-bf25-6aeccf7ea419',
inviteHash: 'test-invite-hash',
isPublicInviteLinkEnabled: true,
};
const mockMemberId = 'member-new-001';
beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [
TeamService,
{
provide: PlatformGraphqlService,
useValue: {
query: jest.fn().mockImplementation((query: string) => {
if (query.includes('currentWorkspace')) {
return Promise.resolve({ currentWorkspace: mockWorkspace });
}
if (query.includes('signUpInWorkspace')) {
return Promise.resolve({ signUpInWorkspace: { workspace: { id: mockWorkspace.id } } });
}
if (query.includes('workspaceMembers')) {
return Promise.resolve({
workspaceMembers: {
edges: [{
node: {
id: mockMemberId,
userId: 'user-new-001',
userEmail: 'ccagent@ramaiahcare.com',
},
}],
},
});
}
if (query.includes('updateWorkspaceMember')) {
return Promise.resolve({ updateWorkspaceMember: { id: mockMemberId } });
}
if (query.includes('updateWorkspaceMemberRole')) {
return Promise.resolve({ updateWorkspaceMemberRole: { id: mockMemberId } });
}
if (query.includes('updateAgent')) {
return Promise.resolve({ updateAgent: { id: 'agent-001', workspaceMemberId: mockMemberId } });
}
return Promise.resolve({});
}),
},
},
{
provide: SessionService,
useValue: {
setCache: jest.fn().mockResolvedValue(undefined),
getCache: jest.fn().mockResolvedValue(null),
},
},
],
}).compile();
service = module.get(TeamService);
platform = module.get(PlatformGraphqlService);
session = module.get(SessionService);
});
// ── Create member (standard flow) ────────────────────────────
it('should create a workspace member with correct 5-step flow', async () => {
const input: CreateMemberInput = {
firstName: 'CC',
lastName: 'Agent',
email: 'ccagent@ramaiahcare.com',
password: 'CcRamaiah@2026',
roleId: 'role-agent-001',
};
const result = await service.createMember(input);
expect(result.id).toBe(mockMemberId);
expect(result.userEmail).toBe('ccagent@ramaiahcare.com');
// Verify the 5-step call chain
const calls = platform.query.mock.calls.map(c => c[0] as string);
// Step 1: fetch workspace context (inviteHash)
expect(calls.some(q => q.includes('currentWorkspace'))).toBe(true);
// Step 2: signUpInWorkspace
expect(calls.some(q => q.includes('signUpInWorkspace'))).toBe(true);
// Step 3: find member by email
expect(calls.some(q => q.includes('workspaceMembers'))).toBe(true);
// Step 4: set name
expect(calls.some(q => q.includes('updateWorkspaceMember'))).toBe(true);
// Step 5: assign role
expect(calls.some(q => q.includes('updateWorkspaceMemberRole'))).toBe(true);
});
// ── Create member with SIP seat link ─────────────────────────
it('should link agent SIP seat when agentId is provided', async () => {
const input: CreateMemberInput = {
firstName: 'CC',
lastName: 'Agent',
email: 'ccagent@ramaiahcare.com',
password: 'CcRamaiah@2026',
roleId: 'role-agent-001',
agentId: 'agent-sip-001',
};
const result = await service.createMember(input);
// Step 6: updateAgent (links workspaceMemberId)
const agentCall = platform.query.mock.calls.find(
c => (c[0] as string).includes('updateAgent'),
);
expect(agentCall).toBeDefined();
expect(agentCall![1]).toMatchObject({
id: 'agent-sip-001',
data: { workspaceMemberId: mockMemberId },
});
});
// ── Validation ───────────────────────────────────────────────
it('should reject missing required fields', async () => {
await expect(
service.createMember({
firstName: '',
lastName: 'Test',
email: 'test@ramaiahcare.com',
password: 'pass',
roleId: 'role-001',
}),
).rejects.toThrow();
});
it('should reject missing email', async () => {
await expect(
service.createMember({
firstName: 'Test',
lastName: 'User',
email: '',
password: 'pass',
roleId: 'role-001',
}),
).rejects.toThrow();
});
// ── Temp password caching ────────────────────────────────────
it('should cache temp password in Redis with 24h TTL', async () => {
const input: CreateMemberInput = {
firstName: 'CC',
lastName: 'Agent',
email: 'ccagent@ramaiahcare.com',
password: 'CcRamaiah@2026',
roleId: 'role-001',
};
await service.createMember(input);
// Redis setCache should be called with the temp password
expect(session.setCache).toHaveBeenCalledWith(
expect.stringContaining('team:tempPassword:'),
'CcRamaiah@2026',
86400, // 24 hours
);
});
// ── Email normalization ──────────────────────────────────────
it('should lowercase email before signUp', async () => {
const input: CreateMemberInput = {
firstName: 'Test',
lastName: 'User',
email: 'CcAgent@RamaiahCare.COM',
password: 'pass',
roleId: 'role-001',
};
await service.createMember(input);
const signUpCall = platform.query.mock.calls.find(
c => (c[0] as string).includes('signUpInWorkspace'),
);
expect(signUpCall![1]?.email).toBe('ccagent@ramaiahcare.com');
});
// ── Workspace context caching ────────────────────────────────
it('should fetch workspace context only once for multiple creates', async () => {
let callCount = 0;
platform.query.mockImplementation((query: string) => {
if (query.includes('currentWorkspace')) {
return Promise.resolve({ currentWorkspace: mockWorkspace });
}
if (query.includes('signUpInWorkspace')) {
return Promise.resolve({ signUpInWorkspace: { workspace: { id: mockWorkspace.id } } });
}
if (query.includes('workspaceMembers')) {
callCount++;
// Return matching email based on call order
const email = callCount <= 1 ? 'a@test.com' : 'b@test.com';
return Promise.resolve({
workspaceMembers: {
edges: [{ node: { id: `member-${callCount}`, userId: 'u', userEmail: email } }],
},
});
}
if (query.includes('updateWorkspaceMember')) return Promise.resolve({ updateWorkspaceMember: { id: 'member-x' } });
if (query.includes('updateWorkspaceMemberRole')) return Promise.resolve({ updateWorkspaceMemberRole: { id: 'member-x' } });
return Promise.resolve({});
});
await service.createMember({ firstName: 'A', lastName: 'B', email: 'a@test.com', password: 'p', roleId: 'r' });
await service.createMember({ firstName: 'C', lastName: 'D', email: 'b@test.com', password: 'p', roleId: 'r' });
const wsCalls = platform.query.mock.calls.filter(
c => (c[0] as string).includes('currentWorkspace'),
);
// Should only fetch workspace context once (cached)
expect(wsCalls.length).toBe(1);
});
});