141 lines
12 KiB
TypeScript
141 lines
12 KiB
TypeScript
import { BadRequestException } from '@nestjs/common';
|
|
import { SendDownstreamRequeueTaskService } from './send-downstream-requeue-task.service';
|
|
|
|
function prismaMock(): Record<string, any> {
|
|
const result: Record<string, any> = {
|
|
cmppDownstreamDelivery: { count: jest.fn(), groupBy: jest.fn(), findFirst: jest.fn(), findMany: jest.fn(), findUnique: jest.fn() },
|
|
cmppDownstreamConnection: { count: jest.fn() },
|
|
downstreamRequeueTask: { findFirst: jest.fn(), findMany: jest.fn(), count: jest.fn(), findUnique: jest.fn(), create: jest.fn(), update: jest.fn(), updateMany: jest.fn() },
|
|
downstreamRequeueTaskItem: { createMany: jest.fn(), count: jest.fn(), groupBy: jest.fn(), findMany: jest.fn(), findFirst: jest.fn(), updateMany: jest.fn(), update: jest.fn() },
|
|
operationLog: { create: jest.fn() },
|
|
downstreamRequeueRateWindow: { deleteMany: jest.fn() },
|
|
$queryRaw: jest.fn(),
|
|
};
|
|
result.$transaction = jest.fn(async (callback: (tx: unknown) => unknown) => callback(result));
|
|
return result;
|
|
}
|
|
|
|
let mock: Record<string, any>;
|
|
let service: SendDownstreamRequeueTaskService;
|
|
|
|
describe('SendDownstreamRequeueTaskService', () => {
|
|
beforeEach(() => {
|
|
process.env.DATABASE_URL = 'postgresql://test:test@127.0.0.1/test';
|
|
mock = prismaMock();
|
|
service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: jest.fn() });
|
|
});
|
|
|
|
it('keeps the selected status in matched counts and returns a signed preview token', async () => {
|
|
mock.cmppDownstreamDelivery.count.mockResolvedValueOnce(8).mockResolvedValueOnce(8);
|
|
mock.cmppDownstreamDelivery.groupBy.mockResolvedValueOnce([{ status: 'pending', _count: { _all: 8 } }]).mockResolvedValueOnce([{ applicationId: 'app-1', _count: { _all: 8 } }]);
|
|
mock.cmppDownstreamDelivery.findFirst.mockResolvedValue({ createdAt: new Date('2026-08-11T00:00:00Z') });
|
|
const result = await service.preview({ status: 'pending' }, 'user-1');
|
|
expect(result).toEqual(expect.objectContaining({ matchedCount: 8, replayableCount: 8, skippedCount: 0, previewToken: expect.stringContaining('.') }));
|
|
expect(mock.cmppDownstreamDelivery.count).toHaveBeenNthCalledWith(1, expect.objectContaining({ where: expect.objectContaining({ status: 'pending' }) }));
|
|
});
|
|
|
|
it('rejects a tampered preview token and short reasons', async () => {
|
|
await expect(service.create({ previewToken: 'invalid.token', reason: '处理事故积压' }, 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
|
await expect(service.create({ previewToken: 'invalid.token', reason: '短' }, 'user-1')).rejects.toBeInstanceOf(BadRequestException);
|
|
});
|
|
|
|
it('materializes only the server-signed preview range', async () => {
|
|
mock.cmppDownstreamDelivery.count.mockResolvedValue(2);
|
|
mock.cmppDownstreamDelivery.groupBy.mockResolvedValueOnce([{ status: 'failed', _count: { _all: 2 } }]).mockResolvedValueOnce([{ applicationId: 'app-1', _count: { _all: 2 } }]);
|
|
mock.cmppDownstreamDelivery.findFirst.mockResolvedValue({ createdAt: new Date() });
|
|
const preview = await service.preview({ applicationId: 'app-1', status: 'failed' }, 'user-1');
|
|
mock.downstreamRequeueTask.findFirst.mockResolvedValue(null);
|
|
mock.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'd-1', applicationId: 'app-1', status: 'failed' }]);
|
|
mock.downstreamRequeueTask.create.mockResolvedValue({ id: 'task-1' });
|
|
mock.downstreamRequeueTask.findUnique.mockResolvedValue({ id: 'task-1' });
|
|
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([]);
|
|
await service.create({ previewToken: preview.previewToken, reason: '处理历史回执积压' }, 'user-1');
|
|
expect(mock.cmppDownstreamDelivery.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ AND: expect.any(Array) }) }));
|
|
expect(mock.downstreamRequeueTaskItem.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ deliveryId: 'd-1', applicationId: 'app-1' })] });
|
|
});
|
|
|
|
it('materializes client-confirmed deliveries from the signed preview range', async () => {
|
|
mock.cmppDownstreamDelivery.count.mockResolvedValue(1);
|
|
mock.cmppDownstreamDelivery.groupBy.mockResolvedValueOnce([{ status: 'delivered', _count: { _all: 1 } }]).mockResolvedValueOnce([{ applicationId: 'app-1', _count: { _all: 1 } }]);
|
|
mock.cmppDownstreamDelivery.findFirst.mockResolvedValue({ createdAt: new Date() });
|
|
const preview = await service.preview({ applicationId: 'app-1', status: 'delivered' }, 'user-1');
|
|
expect(preview).toEqual(expect.objectContaining({ matchedCount: 1, replayableCount: 1, skippedCount: 0 }));
|
|
mock.downstreamRequeueTask.findFirst.mockResolvedValue(null);
|
|
mock.cmppDownstreamDelivery.findMany.mockResolvedValue([{ id: 'd-1', applicationId: 'app-1', status: 'delivered' }]);
|
|
mock.downstreamRequeueTask.create.mockResolvedValue({ id: 'task-1' });
|
|
mock.downstreamRequeueTask.findUnique.mockResolvedValue({ id: 'task-1' });
|
|
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([]);
|
|
await service.create({ previewToken: preview.previewToken, reason: '再次投递客户已确认记录' }, 'user-1');
|
|
expect(mock.downstreamRequeueTaskItem.createMany).toHaveBeenCalledWith({ data: [expect.objectContaining({ deliveryId: 'd-1', previousStatus: 'delivered' })] });
|
|
});
|
|
|
|
it('replays a delivery that was already client-confirmed in the task snapshot', async () => {
|
|
const requeue = jest.fn().mockResolvedValue({ status: 'awaiting_ack' });
|
|
service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: requeue });
|
|
mock.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'd-1', applicationId: 'app-1', status: 'delivered', ackResult: 0, payload: {}, deliveryType: 'receipt', application: { status: 'active', interfaceEnabled: true } });
|
|
mock.downstreamRequeueTaskItem.findFirst.mockResolvedValue(null);
|
|
mock.cmppDownstreamConnection.count.mockResolvedValue(1);
|
|
await expect(service['processItem']('item-1', 'd-1', 'delivered')).resolves.toBe('waiting');
|
|
expect(requeue).toHaveBeenCalledWith('d-1');
|
|
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'waiting_ack' }) }));
|
|
});
|
|
|
|
it('does not replay a record that became delivered after a non-delivered task snapshot', async () => {
|
|
const requeue = jest.fn();
|
|
service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: requeue });
|
|
mock.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'd-1', applicationId: 'app-1', status: 'delivered', ackResult: 0, payload: {}, deliveryType: 'receipt', application: { status: 'active', interfaceEnabled: true } });
|
|
await expect(service['processItem']('item-1', 'd-1', 'failed')).resolves.toBe('skipped');
|
|
expect(requeue).not.toHaveBeenCalled();
|
|
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'skipped', skipReason: '创建任务后已被客户确认' }) }));
|
|
});
|
|
|
|
it('paginates all task items with status and keyword filters', async () => {
|
|
mock.downstreamRequeueTask.findUnique.mockResolvedValue({ id: 'task-1' });
|
|
mock.downstreamRequeueTaskItem.findMany.mockResolvedValue([{ id: 'item-1' }]);
|
|
mock.downstreamRequeueTaskItem.count.mockResolvedValue(21);
|
|
await expect(service.listItems('task-1', { status: 'failed', keyword: 'MSG-1', page: 2, pageSize: 20 })).resolves.toEqual({ items: [{ id: 'item-1' }], total: 21, page: 2, pageSize: 20 });
|
|
expect(mock.downstreamRequeueTaskItem.findMany).toHaveBeenCalledWith(expect.objectContaining({ skip: 20, take: 20, where: expect.objectContaining({ status: 'failed', OR: expect.any(Array) }) }));
|
|
});
|
|
|
|
it('recovers an expired processing claim before scanning queued work', async () => {
|
|
mock.downstreamRequeueTask.findMany.mockResolvedValue([{ id: 'task-1' }]);
|
|
mock.downstreamRequeueTask.updateMany.mockResolvedValue({ count: 1 });
|
|
mock.downstreamRequeueTask.findUnique.mockResolvedValueOnce({ id: 'task-1', taskNo: 'DRT-1', status: 'running', startedAt: new Date(), ratePerSecond: 10, consecutiveFailureLimit: 10, applicationFailures: {} }).mockResolvedValue({ status: 'running' });
|
|
mock.downstreamRequeueTaskItem.updateMany.mockResolvedValue({ count: 1 });
|
|
mock.downstreamRequeueTaskItem.findMany.mockResolvedValue([]);
|
|
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([]);
|
|
await service.runScan();
|
|
expect(mock.downstreamRequeueTaskItem.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ status: 'processing', claimedAt: expect.any(Object) }), data: expect.objectContaining({ status: 'queued', claimedAt: null }) }));
|
|
});
|
|
|
|
it('moves an offline application to waiting_connection without calling Gateway', async () => {
|
|
const requeue = jest.fn();
|
|
service = new SendDownstreamRequeueTaskService(mock as never, { requeueDownstreamDelivery: requeue });
|
|
mock.downstreamRequeueTask.findMany.mockResolvedValue([{ id: 'task-1' }]);
|
|
mock.downstreamRequeueTask.updateMany.mockResolvedValue({ count: 1 });
|
|
mock.downstreamRequeueTask.findUnique.mockResolvedValueOnce({ id: 'task-1', taskNo: 'DRT-1', status: 'queued', startedAt: null, ratePerSecond: 10, consecutiveFailureLimit: 10, applicationFailures: {} }).mockResolvedValue({ status: 'running' });
|
|
mock.downstreamRequeueTaskItem.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'item-1', deliveryId: 'd-1', applicationId: 'app-1' }]).mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
|
mock.downstreamRequeueTaskItem.updateMany.mockResolvedValue({ count: 1 });
|
|
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([{ status: 'waiting_connection', _count: { _all: 1 } }]);
|
|
mock.$queryRaw.mockResolvedValue([{ consumed: 1 }]);
|
|
mock.cmppDownstreamDelivery.findUnique.mockResolvedValue({ id: 'd-1', applicationId: 'app-1', status: 'failed', payload: {}, deliveryType: 'receipt', application: { status: 'active', interfaceEnabled: true } });
|
|
mock.downstreamRequeueTaskItem.findFirst.mockResolvedValue(null);
|
|
mock.cmppDownstreamConnection.count.mockResolvedValue(0);
|
|
await service.runScan();
|
|
expect(requeue).not.toHaveBeenCalled();
|
|
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'waiting_connection' }) }));
|
|
});
|
|
|
|
it('counts ACK failures by application and auto-pauses at the threshold', async () => {
|
|
mock.downstreamRequeueTask.findMany.mockResolvedValue([{ id: 'task-1' }]);
|
|
mock.downstreamRequeueTask.updateMany.mockResolvedValue({ count: 1 });
|
|
mock.downstreamRequeueTask.findUnique.mockResolvedValueOnce({ id: 'task-1', taskNo: 'DRT-1', status: 'running', startedAt: new Date(), ratePerSecond: 10, consecutiveFailureLimit: 1, applicationFailures: {} }).mockResolvedValue({ status: 'running' });
|
|
mock.downstreamRequeueTaskItem.findMany.mockResolvedValueOnce([]).mockResolvedValueOnce([{ id: 'item-1', applicationId: 'app-1', status: 'waiting_ack', delivery: { status: 'rejected', ackResult: 1, ackDeadlineAt: new Date(), lastError: 'ACK Result=1' } }]).mockResolvedValueOnce([]).mockResolvedValueOnce([]).mockResolvedValueOnce([]);
|
|
mock.downstreamRequeueTaskItem.groupBy.mockResolvedValue([{ status: 'failed', _count: { _all: 1 } }]);
|
|
await service.runScan();
|
|
expect(mock.downstreamRequeueTaskItem.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'failed' }) }));
|
|
expect(mock.downstreamRequeueTask.updateMany).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'paused', lastError: expect.stringContaining('app-1') }) }));
|
|
expect(mock.operationLog.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ action: 'gateway.downstream_requeue_task_auto_paused' }) }));
|
|
});
|
|
});
|