Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz Source: /opt/alga-psa on psa.joliet.tech
317 lines
9.6 KiB
TypeScript
317 lines
9.6 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import type { ClaimedUnifiedInboundEmailQueueJob } from '../unifiedInboundEmailQueue';
|
|
|
|
function createRedisClientMock() {
|
|
const chain: any = {
|
|
lRem: vi.fn(() => chain),
|
|
hDel: vi.fn(() => chain),
|
|
zRem: vi.fn(() => chain),
|
|
rPush: vi.fn(() => chain),
|
|
hSet: vi.fn(() => chain),
|
|
zAdd: vi.fn(() => chain),
|
|
exec: vi.fn(async () => []),
|
|
};
|
|
|
|
const client: any = {
|
|
on: vi.fn(),
|
|
connect: vi.fn(async () => undefined),
|
|
rPush: vi.fn(async () => 1),
|
|
brPopLPush: vi.fn(async () => null),
|
|
eval: vi.fn(async () => JSON.stringify({ status: 'empty' })),
|
|
multi: vi.fn(() => chain),
|
|
zRangeByScore: vi.fn(async () => []),
|
|
hGet: vi.fn(async () => null),
|
|
zRem: vi.fn(async () => 1),
|
|
};
|
|
|
|
return { client, chain };
|
|
}
|
|
|
|
function buildClaim(overrides?: Partial<ClaimedUnifiedInboundEmailQueueJob>): ClaimedUnifiedInboundEmailQueueJob {
|
|
const baseJob = {
|
|
jobId: 'job-1',
|
|
schemaVersion: 1 as const,
|
|
tenantId: 'tenant-1',
|
|
providerId: 'provider-1',
|
|
provider: 'microsoft' as const,
|
|
pointer: {
|
|
subscriptionId: 'sub-1',
|
|
messageId: 'msg-1',
|
|
},
|
|
enqueuedAt: new Date().toISOString(),
|
|
attempt: 0,
|
|
maxAttempts: 5,
|
|
};
|
|
|
|
return {
|
|
job: baseJob,
|
|
originalPayload: JSON.stringify(baseJob),
|
|
consumerId: 'consumer-1',
|
|
claimedAt: new Date().toISOString(),
|
|
leaseExpiresAt: new Date(Date.now() + 60_000).toISOString(),
|
|
...(overrides || {}),
|
|
} as ClaimedUnifiedInboundEmailQueueJob;
|
|
}
|
|
|
|
async function loadQueueModule() {
|
|
vi.resetModules();
|
|
|
|
const { client, chain } = createRedisClientMock();
|
|
|
|
vi.doMock('redis', () => ({
|
|
createClient: vi.fn(() => client),
|
|
}));
|
|
|
|
vi.doMock('@alga-psa/core/secrets', () => ({
|
|
getSecret: vi.fn(async () => null),
|
|
}));
|
|
|
|
const module = await import('../unifiedInboundEmailQueue');
|
|
return { module, client, chain };
|
|
}
|
|
|
|
describe('Unified inbound pointer queue primitives', () => {
|
|
beforeEach(() => {
|
|
delete process.env.UNIFIED_INBOUND_EMAIL_QUEUE_KEY;
|
|
delete process.env.UNIFIED_INBOUND_EMAIL_PROCESSING_QUEUE_KEY;
|
|
delete process.env.UNIFIED_INBOUND_EMAIL_INFLIGHT_HASH_KEY;
|
|
delete process.env.UNIFIED_INBOUND_EMAIL_INFLIGHT_LEASE_KEY;
|
|
delete process.env.UNIFIED_INBOUND_EMAIL_DLQ_KEY;
|
|
delete process.env.UNIFIED_INBOUND_EMAIL_QUEUE_MAX_ATTEMPTS;
|
|
});
|
|
|
|
it('T018: successful processing ACK removes the job from processing/inflight structures', async () => {
|
|
const { module, chain } = await loadQueueModule();
|
|
const claim = buildClaim();
|
|
|
|
await module.ackUnifiedInboundEmailQueueJob(claim);
|
|
|
|
expect(chain.lRem).toHaveBeenCalledWith('email:inbound:unified:pointer:processing', 1, claim.originalPayload);
|
|
expect(chain.hDel).toHaveBeenCalledWith('email:inbound:unified:pointer:inflight', claim.job.jobId);
|
|
expect(chain.zRem).toHaveBeenCalledWith('email:inbound:unified:pointer:lease', claim.job.jobId);
|
|
expect(chain.exec).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('T020: unacknowledged jobs are reclaimed after lease timeout and resurfaced to ready queue', async () => {
|
|
const { module, client, chain } = await loadQueueModule();
|
|
const claim = buildClaim();
|
|
|
|
client.zRangeByScore.mockResolvedValueOnce([claim.job.jobId]);
|
|
client.hGet.mockResolvedValueOnce(JSON.stringify(claim));
|
|
|
|
const reclaimed = await module.reclaimExpiredUnifiedInboundEmailQueueJobs(10);
|
|
|
|
expect(reclaimed).toBe(1);
|
|
expect(chain.lRem).toHaveBeenCalledWith('email:inbound:unified:pointer:processing', 1, claim.originalPayload);
|
|
expect(chain.hDel).toHaveBeenCalledWith('email:inbound:unified:pointer:inflight', claim.job.jobId);
|
|
expect(chain.zRem).toHaveBeenCalledWith('email:inbound:unified:pointer:lease', claim.job.jobId);
|
|
expect(chain.rPush).toHaveBeenCalledWith('email:inbound:unified:pointer:ready', claim.originalPayload);
|
|
});
|
|
|
|
it('T024: claim returns a parsed in-flight record from the atomic Lua envelope', async () => {
|
|
const { module, client } = await loadQueueModule();
|
|
const claim = buildClaim();
|
|
|
|
client.eval.mockResolvedValueOnce(
|
|
JSON.stringify({
|
|
status: 'claimed',
|
|
claim: JSON.stringify(claim),
|
|
})
|
|
);
|
|
|
|
const claimed = await module.claimUnifiedInboundEmailQueueJob({
|
|
consumerId: claim.consumerId,
|
|
blockSeconds: 0,
|
|
claimTtlMs: 60_000,
|
|
});
|
|
|
|
expect(claimed).toMatchObject({
|
|
consumerId: claim.consumerId,
|
|
originalPayload: claim.originalPayload,
|
|
job: expect.objectContaining({
|
|
jobId: claim.job.jobId,
|
|
}),
|
|
});
|
|
expect(client.eval).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('T021: failed consume increments attempt count when requeued', async () => {
|
|
const { module, chain } = await loadQueueModule();
|
|
const claim = buildClaim({
|
|
job: {
|
|
...buildClaim().job,
|
|
attempt: 1,
|
|
maxAttempts: 5,
|
|
},
|
|
});
|
|
|
|
chain.exec.mockResolvedValueOnce([1, 1, 1, 4]);
|
|
const result = await module.failUnifiedInboundEmailQueueJob({
|
|
claim,
|
|
error: 'temporary_failure',
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
action: 'retried',
|
|
attempt: 2,
|
|
queueDepth: 4,
|
|
});
|
|
|
|
const [readyQueueKey, requeuedPayloadRaw] = chain.rPush.mock.calls[0];
|
|
expect(readyQueueKey).toBe('email:inbound:unified:pointer:ready');
|
|
const requeuedPayload = JSON.parse(requeuedPayloadRaw);
|
|
expect(requeuedPayload.attempt).toBe(2);
|
|
});
|
|
|
|
it('T022: failed consume moves job to DLQ when max attempts are exceeded', async () => {
|
|
const { module, chain } = await loadQueueModule();
|
|
const claim = buildClaim({
|
|
job: {
|
|
...buildClaim().job,
|
|
attempt: 4,
|
|
maxAttempts: 5,
|
|
},
|
|
});
|
|
|
|
chain.exec.mockResolvedValueOnce([1, 1, 1, 2]);
|
|
const result = await module.failUnifiedInboundEmailQueueJob({
|
|
claim,
|
|
error: 'permanent_failure',
|
|
});
|
|
|
|
expect(result).toMatchObject({
|
|
action: 'dlq',
|
|
attempt: 5,
|
|
queueDepth: 2,
|
|
});
|
|
|
|
const [dlqKey, dlqPayloadRaw] = chain.rPush.mock.calls[0];
|
|
expect(dlqKey).toBe('email:inbound:unified:pointer:dlq');
|
|
const dlqPayload = JSON.parse(dlqPayloadRaw);
|
|
expect(dlqPayload).toMatchObject({
|
|
reason: 'permanent_failure',
|
|
job: expect.objectContaining({
|
|
attempt: 5,
|
|
}),
|
|
});
|
|
});
|
|
|
|
it('T025: queue payload guard rejects raw MIME and attachment-like fields', async () => {
|
|
const { module } = await loadQueueModule();
|
|
|
|
await expect(
|
|
module.enqueueUnifiedInboundEmailQueueJob({
|
|
tenantId: 'tenant-1',
|
|
providerId: 'provider-1',
|
|
provider: 'microsoft',
|
|
pointer: {
|
|
subscriptionId: 'sub-1',
|
|
messageId: 'msg-1',
|
|
},
|
|
// Explicitly violating pointer-only contract.
|
|
...( { emailData: { id: 'msg-1' } } as any),
|
|
})
|
|
).rejects.toThrow('pointer-only');
|
|
});
|
|
|
|
it('T032: enqueue success/failure logs include tenant/provider/pointer identifiers', async () => {
|
|
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
const { module, client } = await loadQueueModule();
|
|
|
|
client.rPush.mockResolvedValueOnce(1);
|
|
await module.enqueueUnifiedInboundEmailQueueJob({
|
|
tenantId: 'tenant-1',
|
|
providerId: 'provider-1',
|
|
provider: 'microsoft',
|
|
pointer: {
|
|
subscriptionId: 'sub-1',
|
|
messageId: 'msg-1',
|
|
},
|
|
});
|
|
|
|
client.rPush.mockRejectedValueOnce(new Error('redis down'));
|
|
await expect(
|
|
module.enqueueUnifiedInboundEmailQueueJob({
|
|
tenantId: 'tenant-1',
|
|
providerId: 'provider-1',
|
|
provider: 'microsoft',
|
|
pointer: {
|
|
subscriptionId: 'sub-1',
|
|
messageId: 'msg-2',
|
|
},
|
|
})
|
|
).rejects.toThrow('redis down');
|
|
|
|
expect(logSpy).toHaveBeenCalledWith(
|
|
'[UnifiedInboundEmailQueue] enqueue',
|
|
expect.objectContaining({
|
|
event: 'inbound_email_queue_enqueue',
|
|
tenantId: 'tenant-1',
|
|
provider: 'microsoft',
|
|
pointerMessageId: 'msg-1',
|
|
})
|
|
);
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
'[UnifiedInboundEmailQueue] enqueue_failed',
|
|
expect.objectContaining({
|
|
event: 'inbound_email_queue_enqueue_failed',
|
|
tenantId: 'tenant-1',
|
|
provider: 'microsoft',
|
|
pointerMessageId: 'msg-2',
|
|
})
|
|
);
|
|
|
|
logSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
});
|
|
|
|
it('T033: retry and DLQ logs include attempts and terminal reasons', async () => {
|
|
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined);
|
|
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined);
|
|
|
|
const { module, chain } = await loadQueueModule();
|
|
|
|
chain.exec.mockResolvedValueOnce([1, 1, 1, 1]);
|
|
await module.failUnifiedInboundEmailQueueJob({
|
|
claim: buildClaim(),
|
|
error: 'transient_error',
|
|
});
|
|
|
|
chain.exec.mockResolvedValueOnce([1, 1, 1, 2]);
|
|
await module.failUnifiedInboundEmailQueueJob({
|
|
claim: buildClaim({
|
|
job: {
|
|
...buildClaim().job,
|
|
attempt: 4,
|
|
maxAttempts: 5,
|
|
},
|
|
}),
|
|
error: 'terminal_error',
|
|
});
|
|
|
|
expect(warnSpy).toHaveBeenCalledWith(
|
|
'[UnifiedInboundEmailQueue] retry',
|
|
expect.objectContaining({
|
|
event: 'inbound_email_queue_retry',
|
|
attempt: 1,
|
|
maxAttempts: 5,
|
|
reason: 'transient_error',
|
|
})
|
|
);
|
|
expect(errorSpy).toHaveBeenCalledWith(
|
|
'[UnifiedInboundEmailQueue] dlq',
|
|
expect.objectContaining({
|
|
event: 'inbound_email_queue_dlq',
|
|
attempt: 5,
|
|
maxAttempts: 5,
|
|
reason: 'terminal_error',
|
|
})
|
|
);
|
|
|
|
warnSpy.mockRestore();
|
|
errorSpy.mockRestore();
|
|
});
|
|
});
|