feat(worker): handleStarReaction approval core with tests
Implement approval core logic for Task 5: when admin reacts to WhatsApp message with ⭐, verify admin status, update message to APPROVED, create Approval record, find active SyncRoutes from source group, and return ForwardJobData[] for each target group. All 7 tests pass. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
import { handleStarReaction } from './approval';
|
||||
import { NormalizedReaction } from '@tower/types';
|
||||
|
||||
function makeReaction(overrides: Partial<NormalizedReaction> = {}): NormalizedReaction {
|
||||
return {
|
||||
reactorJid: '919876543210@s.whatsapp.net',
|
||||
targetMsgId: 'TARGET_MSG_123',
|
||||
sourceGroupJid: '120363043312345678@g.us',
|
||||
emoji: '⭐',
|
||||
accountId: 'acc_1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const adminJids = ['919876543210@s.whatsapp.net'];
|
||||
|
||||
describe('handleStarReaction', () => {
|
||||
it('returns null for non-star emoji', async () => {
|
||||
const result = await handleStarReaction(makeReaction({ emoji: '👍' }), adminJids, {} as any);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when reactor is not an admin', async () => {
|
||||
const result = await handleStarReaction(
|
||||
makeReaction({ reactorJid: 'stranger@s.whatsapp.net' }),
|
||||
adminJids,
|
||||
{} as any,
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when message not found', async () => {
|
||||
const prisma = { message: { findUnique: jest.fn().mockResolvedValue(null) } } as any;
|
||||
const result = await handleStarReaction(makeReaction(), adminJids, prisma);
|
||||
expect(result).toBeNull();
|
||||
expect(prisma.message.findUnique).toHaveBeenCalledWith({
|
||||
where: {
|
||||
platform_platformMsgId: { platform: 'whatsapp', platformMsgId: 'TARGET_MSG_123' },
|
||||
},
|
||||
include: {
|
||||
approval: true,
|
||||
sourceGroup: {
|
||||
include: {
|
||||
syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null when message status is not PENDING', async () => {
|
||||
const prisma = {
|
||||
message: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'msg_1',
|
||||
status: 'REJECTED',
|
||||
approval: null,
|
||||
sourceGroup: { name: 'Test Group', syncRoutesFrom: [] },
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
expect(await handleStarReaction(makeReaction(), adminJids, prisma)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null when message is already approved (approval record exists)', async () => {
|
||||
const prisma = {
|
||||
message: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'msg_1',
|
||||
status: 'APPROVED',
|
||||
approval: { id: 'appr_1' },
|
||||
sourceGroup: { name: 'Test Group', syncRoutesFrom: [] },
|
||||
}),
|
||||
},
|
||||
} as any;
|
||||
expect(await handleStarReaction(makeReaction(), adminJids, prisma)).toBeNull();
|
||||
});
|
||||
|
||||
it('approves message and returns empty array when no sync routes', async () => {
|
||||
const prisma = {
|
||||
message: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'msg_1',
|
||||
status: 'PENDING',
|
||||
approval: null,
|
||||
content: 'hello',
|
||||
senderName: 'Alice',
|
||||
sourceGroup: { name: 'UP Parivar Dallas', syncRoutesFrom: [] },
|
||||
}),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
approval: { create: jest.fn().mockResolvedValue({}) },
|
||||
} as any;
|
||||
|
||||
const result = await handleStarReaction(makeReaction(), adminJids, prisma);
|
||||
expect(result).toEqual([]);
|
||||
expect(prisma.message.update).toHaveBeenCalledWith({
|
||||
where: { id: 'msg_1' },
|
||||
data: { status: 'APPROVED' },
|
||||
});
|
||||
expect(prisma.approval.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
messageId: 'msg_1',
|
||||
adminId: '919876543210@s.whatsapp.net',
|
||||
decision: 'APPROVED',
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns ForwardJobData for each active sync route', async () => {
|
||||
const prisma = {
|
||||
message: {
|
||||
findUnique: jest.fn().mockResolvedValue({
|
||||
id: 'msg_1',
|
||||
status: 'PENDING',
|
||||
approval: null,
|
||||
content: 'important announcement',
|
||||
senderName: 'Bob',
|
||||
sourceGroup: {
|
||||
name: 'Source Group',
|
||||
syncRoutesFrom: [
|
||||
{ targetGroup: { platformId: '999@g.us', accountId: 'acc_2' } },
|
||||
{ targetGroup: { platformId: '888@g.us', accountId: null } },
|
||||
],
|
||||
},
|
||||
}),
|
||||
update: jest.fn().mockResolvedValue({}),
|
||||
},
|
||||
approval: { create: jest.fn().mockResolvedValue({}) },
|
||||
} as any;
|
||||
|
||||
const result = await handleStarReaction(makeReaction(), adminJids, prisma);
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result![0]).toMatchObject({
|
||||
messageId: 'msg_1',
|
||||
content: 'important announcement',
|
||||
sourceGroupName: 'Source Group',
|
||||
senderName: 'Bob',
|
||||
toGroupJid: '999@g.us',
|
||||
fromAccountId: 'acc_2',
|
||||
});
|
||||
// falls back to reaction.accountId when targetGroup.accountId is null
|
||||
expect(result![1]).toMatchObject({
|
||||
toGroupJid: '888@g.us',
|
||||
fromAccountId: 'acc_1',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NormalizedReaction, ForwardJobData } from '@tower/types';
|
||||
|
||||
export async function handleStarReaction(
|
||||
reaction: NormalizedReaction,
|
||||
adminJids: string[],
|
||||
prisma: any,
|
||||
): Promise<ForwardJobData[] | null> {
|
||||
if (reaction.emoji !== '⭐') return null;
|
||||
if (!adminJids.includes(reaction.reactorJid)) return null;
|
||||
|
||||
const message = await prisma.message.findUnique({
|
||||
where: {
|
||||
platform_platformMsgId: {
|
||||
platform: 'whatsapp',
|
||||
platformMsgId: reaction.targetMsgId,
|
||||
},
|
||||
},
|
||||
include: {
|
||||
approval: true,
|
||||
sourceGroup: {
|
||||
include: {
|
||||
syncRoutesFrom: { where: { isActive: true }, include: { targetGroup: true } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!message) return null;
|
||||
if (message.status !== 'PENDING') return null;
|
||||
if (message.approval) return null;
|
||||
|
||||
await prisma.message.update({
|
||||
where: { id: message.id },
|
||||
data: { status: 'APPROVED' },
|
||||
});
|
||||
|
||||
await prisma.approval.create({
|
||||
data: {
|
||||
messageId: message.id,
|
||||
adminId: reaction.reactorJid,
|
||||
decision: 'APPROVED',
|
||||
},
|
||||
});
|
||||
|
||||
const jobs: ForwardJobData[] = message.sourceGroup.syncRoutesFrom.map((route: any) => ({
|
||||
messageId: message.id,
|
||||
content: message.content,
|
||||
sourceGroupName: message.sourceGroup.name,
|
||||
senderName: message.senderName ?? undefined,
|
||||
toGroupJid: route.targetGroup.platformId,
|
||||
fromAccountId: route.targetGroup.accountId ?? reaction.accountId,
|
||||
}));
|
||||
|
||||
return jobs;
|
||||
}
|
||||
Reference in New Issue
Block a user