2d88f18f75
* feat(GRO-106): messaging schema + migrations - Add conversations, messages, message_attachments, message_consent_events tables - Add messagingChannelEnum, messageDirectionEnum, messageStatusEnum, messageConsentKindEnum - Extend business_settings with messagingPhoneNumber and telnyxMessagingProfileId columns - Add required indexes and unique constraints with cascade-on-delete FKs - Add migration 0030_messaging.sql Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(GRO-981): restore journal entries and add DESC to indexes - _journal.json: restore idx 28 (0028_sms_reminders), add idx 29 (0029_db_indexes_constraints), renumber 0030_messaging to idx 30 (was missing 0028 and 0029 entries — they were silently skipped) - schema.ts: add .desc() to conversations.lastMessageAt and messages.createdAt indexes per spec - 0030_messaging.sql: add DESC to both generated index statements Co-Authored-By: Paperclip <noreply@paperclip.ing> * feat(GRO-106): inbound Telnyx webhook + persistence - Add POST /api/webhooks/telnyx/messaging route with HMAC signature verification - Add services/messaging/inbound.ts: findOrCreateConversation, upsertMessage (idempotent on providerMessageId), delivery receipt handling - Register telnyxWebhooksRouter in index.ts (before auth middleware) - Add unit tests for signature validation, find-or-create, idempotent insert, delivery receipt Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(GRO-982): address all QA blocking failures - #7: Extract validateTelnyxSignature in sms.ts as standalone exported fn, reuse in TelnyxProvider.validateWebhookSignature and telnyx.ts route - #1: Replace uuid v4 import with crypto.randomUUID() (built-in, no dep) - #2: Remove updatedAt from messages update in handleMessageFinalized (no such column exists) - #3: Fix test import path ../../ → ../../../ for telnyx route import - #4: validateTelnyxSignature accepts string | undefined | null to match Hono c.req.header() return type - #5&6: Add null guards for .returning() results in findOrCreateConversation and upsertMessage - #8: Remove dead buildFindOrCreateConversationParams function - #9: Remove unused imports (messageDirectionEnum, messageStatusEnum, resolveBusinessIdByMessagingNumber in test) - #10: Wrap upsertMessage insert in try/catch; unique violation returns {isNew: false} instead of crashing - #11: Add EOF newlines to all modified files Co-Authored-By: Paperclip <noreply@paperclip.ing> * chore: add uuid dependency for messaging services * fix(GRO-982): address 5 test failures in inbound webhook - Fix signature route tests: use /messaging not full mount path - Fix handleMessageReceived mock order: business lookup first - Fix stale mock state: add full mockReset in handleMessageFinalized beforeEach - Fix delivery logic: set delivered for all message.finalized events - Deduplicate test that was accidentally added twice Co-Authored-By: Paperclip <noreply@paperclip.ing> * fix(GRO-982): look up or create client by phone before inserting conversation Fixes FK constraint violation where clientId was set to businessSettings.id or a random UUID. Now looks up clients.phone = clientPhone first; if no match, creates a placeholder client with phone as name and a placeholder email. * fix(GRO-982): address QA round 4 blocking failures - Fix URL in signature tests: use /messaging not full path - Reorder mocks: businessSettings first, then conversations, clients, messages - Add mockDb.mockReset in handleMessageFinalized beforeEach - Remove direction guard: set delivered for any message.finalized * fix(GRO-982): add missing message insert mock in handleMessageReceived test * fix(GRO-982): simplify test mocks to match actual code flow --------- Co-authored-by: Chris Farhood <chris@farhood.org> Co-authored-by: Paperclip <noreply@paperclip.ing>
314 lines
9.7 KiB
TypeScript
314 lines
9.7 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import {
|
|
findOrCreateConversation,
|
|
upsertMessage,
|
|
handleMessageReceived,
|
|
handleMessageFinalized,
|
|
TelnyxMessageReceivedPayload,
|
|
} from "../inbound.js";
|
|
import * as schema from "@groombook/db";
|
|
|
|
vi.mock("@groombook/db", () => ({
|
|
getDb: vi.fn(),
|
|
conversations: { id: "", businessId: "", clientId: "", externalNumber: "", businessNumber: "", channel: "", lastMessageAt: null, status: "", createdAt: null, updatedAt: null },
|
|
messages: { id: "", conversationId: "", direction: "", body: "", status: "", providerMessageId: "", sentByStaffId: null, createdAt: null, deliveredAt: null, readByClientAt: null },
|
|
businessSettings: { id: "", messagingPhoneNumber: "" },
|
|
clients: { id: "", name: "", email: "", phone: "", status: "" },
|
|
eq: vi.fn(),
|
|
and: vi.fn(),
|
|
sql: vi.fn(),
|
|
}));
|
|
|
|
const mockDb = {
|
|
select: vi.fn().mockReturnThis(),
|
|
from: vi.fn().mockReturnThis(),
|
|
where: vi.fn().mockReturnThis(),
|
|
limit: vi.fn().mockReturnThis(),
|
|
insert: vi.fn().mockReturnThis(),
|
|
update: vi.fn().mockReturnThis(),
|
|
returning: vi.fn().mockReturnThis(),
|
|
};
|
|
|
|
vi.mocked(schema.getDb).mockReturnValue(mockDb as unknown as ReturnType<typeof schema.getDb>);
|
|
|
|
const makePayload = (
|
|
eventType: "message.received" | "message.sent" | "message.finalized",
|
|
messageId: string,
|
|
fromPhone: string,
|
|
toPhone: string,
|
|
body = "Hello"
|
|
): TelnyxMessageReceivedPayload => ({
|
|
data: {
|
|
id: "evt-1",
|
|
event_type: eventType,
|
|
payload: {
|
|
message: {
|
|
id: messageId,
|
|
from: { phone: fromPhone, carrier: "carrier" },
|
|
to: [{ phone: toPhone }],
|
|
body,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
describe("signature validation via route", () => {
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
});
|
|
|
|
it("returns 401 when telnyx-signature header is missing", async () => {
|
|
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
|
|
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
|
|
const req = new Request("http://localhost/messaging", {
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/json" },
|
|
body: payload,
|
|
});
|
|
const res = await telnyxWebhooksRouter.fetch(req);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
|
|
it("returns 401 when signature does not match", async () => {
|
|
process.env.TELNYX_WEBHOOK_SECRET = "test-secret";
|
|
const { telnyxWebhooksRouter } = await import("../../../routes/webhooks/telnyx.js");
|
|
const payload = JSON.stringify(makePayload("message.received", "msg-123", "+1555111", "+1555222"));
|
|
const req = new Request("http://localhost/messaging", {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"telnyx-signature": "sha256=bad",
|
|
},
|
|
body: payload,
|
|
});
|
|
const res = await telnyxWebhooksRouter.fetch(req);
|
|
expect(res.status).toBe(401);
|
|
});
|
|
});
|
|
|
|
describe("findOrCreateConversation", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockDb.select.mockReset();
|
|
mockDb.from.mockReset();
|
|
mockDb.where.mockReset();
|
|
mockDb.limit.mockReset();
|
|
mockDb.insert.mockReset();
|
|
mockDb.update.mockReset();
|
|
mockDb.returning.mockReset();
|
|
});
|
|
|
|
it("returns existing conversation when found", async () => {
|
|
mockDb.select.mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([{ id: "conv-1", clientId: "client-1" }]),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
|
|
expect(result.id).toBe("conv-1");
|
|
});
|
|
|
|
it("creates new conversation when none exists", async () => {
|
|
mockDb.select.mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([]),
|
|
}),
|
|
}),
|
|
});
|
|
mockDb.insert.mockReturnValue({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockReturnValue([{ id: "conv-2", clientId: "client-2" }]),
|
|
}),
|
|
});
|
|
|
|
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
|
|
expect(result.id).toBe("conv-2");
|
|
});
|
|
|
|
it("creates placeholder client for unknown phone then creates conversation", async () => {
|
|
mockDb.select
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([]),
|
|
}),
|
|
}),
|
|
})
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([]),
|
|
}),
|
|
}),
|
|
});
|
|
mockDb.insert.mockReturnValue({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockReturnValue([{ id: "conv-3", clientId: "client-3" }]),
|
|
}),
|
|
});
|
|
|
|
const result = await findOrCreateConversation("biz-1", "+1555111", "+1555222");
|
|
expect(result.id).toBe("conv-3");
|
|
expect(result.clientId).toBe("client-3");
|
|
});
|
|
});
|
|
|
|
describe("upsertMessage", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("returns isNew=false when message with providerMessageId already exists", async () => {
|
|
mockDb.select.mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([{ id: "msg-existing" }]),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
const result = await upsertMessage("msg-123", "conv-1", "inbound", "Hello", "received");
|
|
expect(result.isNew).toBe(false);
|
|
expect(result.id).toBe("msg-existing");
|
|
});
|
|
|
|
it("inserts new message and returns isNew=true", async () => {
|
|
mockDb.select.mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([]),
|
|
}),
|
|
}),
|
|
});
|
|
mockDb.insert.mockReturnValue({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
|
|
}),
|
|
});
|
|
|
|
const result = await upsertMessage("msg-new-123", "conv-1", "inbound", "New message", "queued");
|
|
expect(result.isNew).toBe(true);
|
|
expect(result.id).toBe("msg-new");
|
|
});
|
|
});
|
|
|
|
describe("handleMessageReceived", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockDb.select.mockReset();
|
|
mockDb.from.mockReset();
|
|
mockDb.where.mockReset();
|
|
mockDb.limit.mockReset();
|
|
mockDb.insert.mockReset();
|
|
mockDb.update.mockReset();
|
|
mockDb.returning.mockReset();
|
|
mockDb.select.mockImplementation(() => ({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([]),
|
|
}),
|
|
}),
|
|
}));
|
|
});
|
|
|
|
it("returns 404 when no business owns the to number", async () => {
|
|
const payload = makePayload("message.received", "msg-123", "+1555111", "+1555000");
|
|
await expect(handleMessageReceived(payload)).rejects.toThrow("No business owns messaging number");
|
|
});
|
|
|
|
it("creates conversation and message for valid inbound", async () => {
|
|
mockDb.select
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([{ id: "biz-1" }]),
|
|
}),
|
|
}),
|
|
})
|
|
.mockReturnValueOnce({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([]),
|
|
}),
|
|
}),
|
|
});
|
|
mockDb.insert
|
|
.mockReturnValueOnce({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockReturnValue([{ id: "client-new" }]),
|
|
}),
|
|
})
|
|
.mockReturnValueOnce({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockReturnValue([{ id: "conv-new", clientId: "client-new" }]),
|
|
}),
|
|
});
|
|
mockDb.update.mockReturnValueOnce({
|
|
set: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({}),
|
|
}),
|
|
});
|
|
mockDb.insert.mockReturnValueOnce({
|
|
values: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockReturnValue([{ id: "msg-new" }]),
|
|
}),
|
|
});
|
|
|
|
const payload = makePayload("message.received", "msg-abc", "+1555111", "+1555222", "Test message");
|
|
const result = await handleMessageReceived(payload);
|
|
expect(result.messageId).toBe("msg-new");
|
|
});
|
|
});
|
|
|
|
describe("handleMessageFinalized", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mockDb.select.mockReset();
|
|
mockDb.from.mockReset();
|
|
mockDb.where.mockReset();
|
|
mockDb.limit.mockReset();
|
|
mockDb.insert.mockReset();
|
|
mockDb.update.mockReset();
|
|
mockDb.returning.mockReset();
|
|
});
|
|
|
|
it("returns null when message not found", async () => {
|
|
mockDb.select.mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([]),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
const payload = makePayload("message.finalized", "msg-unknown", "+1555111", "+1555222");
|
|
const result = await handleMessageFinalized(payload);
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it("updates status to delivered for finalized inbound", async () => {
|
|
mockDb.select.mockReturnValue({
|
|
from: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
limit: vi.fn().mockReturnValue([{ id: "msg-1", status: "sent" }]),
|
|
}),
|
|
}),
|
|
});
|
|
mockDb.update.mockReturnValue({
|
|
set: vi.fn().mockReturnValue({
|
|
where: vi.fn().mockReturnValue({
|
|
returning: vi.fn().mockReturnValue([{ id: "msg-1" }]),
|
|
}),
|
|
}),
|
|
});
|
|
|
|
const payload = makePayload("message.finalized", "msg-1", "+1555111", "+1555222");
|
|
const result = await handleMessageFinalized(payload);
|
|
expect(result?.newStatus).toBe("delivered");
|
|
});
|
|
});
|