diff --git a/src/provider-web.test.ts b/src/web/auto-reply.test.ts similarity index 50% rename from src/provider-web.test.ts rename to src/web/auto-reply.test.ts index 73601f237..a8aee869c 100644 --- a/src/provider-web.test.ts +++ b/src/web/auto-reply.test.ts @@ -1,330 +1,30 @@ import crypto from "node:crypto"; -import { EventEmitter } from "node:events"; -import fsSync from "node:fs"; -import os from "node:os"; -import path from "node:path"; +import fs from "node:fs/promises"; import sharp from "sharp"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { MockBaileysSocket } from "../test/mocks/baileys.js"; -import { createMockBaileys } from "../test/mocks/baileys.js"; -vi.mock("@whiskeysockets/baileys", () => { - const created = createMockBaileys(); - (globalThis as Record)[ - Symbol.for("warelay:lastSocket") - ] = created.lastSocket; - return created.mod; -}); - -vi.mock("./media/store.js", () => ({ - saveMediaBuffer: vi - .fn() - .mockImplementation(async (_buf: Buffer, contentType?: string) => ({ - id: "mid", - path: "/tmp/mid", - size: _buf.length, - contentType, - })), -})); - -let loadConfigMock: () => unknown = () => ({}); -vi.mock("./config/config.js", () => ({ - loadConfig: () => loadConfigMock(), -})); - -function getLastSocket(): MockBaileysSocket { - const getter = (globalThis as Record)[ - Symbol.for("warelay:lastSocket") - ]; - if (typeof getter === "function") - return (getter as () => MockBaileysSocket)(); - if (!getter) throw new Error("Baileys mock not initialized"); - throw new Error("Invalid Baileys socket getter"); -} - -vi.mock("qrcode-terminal", () => ({ - default: { generate: vi.fn() }, - generate: vi.fn(), -})); - -import { monitorWebProvider } from "./index.js"; -import { resetLogger, setLoggerOverride } from "./logging.js"; import { - createWaSocket, - loginWeb, - logWebSelfId, - monitorWebInbox, - sendMessageWeb, - waitForWaConnection, -} from "./provider-web.js"; + resetBaileysMocks, + resetLoadConfigMock, + setLoadConfigMock, +} from "./test-helpers.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { monitorWebProvider } from "./auto-reply.js"; -const baileys = (await import( - "@whiskeysockets/baileys" -)) as unknown as typeof import("@whiskeysockets/baileys") & { - makeWASocket: ReturnType; - useMultiFileAuthState: ReturnType; - fetchLatestBaileysVersion: ReturnType; - makeCacheableSignalKeyStore: ReturnType; -}; - -describe("provider-web", () => { +describe("web auto-reply", () => { beforeEach(() => { vi.clearAllMocks(); - loadConfigMock = () => ({}); - const recreated = createMockBaileys(); - (globalThis as Record)[ - Symbol.for("warelay:lastSocket") - ] = recreated.lastSocket; - baileys.makeWASocket.mockImplementation(recreated.mod.makeWASocket); - baileys.useMultiFileAuthState.mockImplementation( - recreated.mod.useMultiFileAuthState, - ); - baileys.fetchLatestBaileysVersion.mockImplementation( - recreated.mod.fetchLatestBaileysVersion, - ); - baileys.makeCacheableSignalKeyStore.mockImplementation( - recreated.mod.makeCacheableSignalKeyStore, - ); + resetBaileysMocks(); + resetLoadConfigMock(); }); afterEach(() => { - vi.useRealTimers(); resetLogger(); setLoggerOverride(null); + vi.useRealTimers(); }); - it("creates WA socket with QR handler", async () => { - await createWaSocket(true, false); - const makeWASocket = baileys.makeWASocket as ReturnType; - expect(makeWASocket).toHaveBeenCalledWith( - expect.objectContaining({ printQRInTerminal: false }), - ); - const passed = makeWASocket.mock.calls[0][0]; - const passedLogger = ( - passed as { logger?: { level?: string; trace?: unknown } } - ).logger; - expect(passedLogger?.level).toBe("silent"); - expect(typeof passedLogger?.trace).toBe("function"); - const sock = getLastSocket(); - const saveCreds = ( - await baileys.useMultiFileAuthState.mock.results[0].value - ).saveCreds; - // trigger creds.update listener - sock.ev.emit("creds.update", {}); - expect(saveCreds).toHaveBeenCalled(); - }); - - it("waits for connection open", async () => { - const ev = new EventEmitter(); - const promise = waitForWaConnection({ ev } as unknown as ReturnType< - typeof baileys.makeWASocket - >); - ev.emit("connection.update", { connection: "open" }); - await expect(promise).resolves.toBeUndefined(); - }); - - it("rejects when connection closes", async () => { - const ev = new EventEmitter(); - const promise = waitForWaConnection({ ev } as unknown as ReturnType< - typeof baileys.makeWASocket - >); - ev.emit("connection.update", { - connection: "close", - lastDisconnect: new Error("bye"), - }); - await expect(promise).rejects.toBeInstanceOf(Error); - }); - - it("sends message via web and closes socket", async () => { - await sendMessageWeb("+1555", "hi", { verbose: false }); - const sock = getLastSocket(); - expect(sock.sendMessage).toHaveBeenCalled(); - expect(sock.ws.close).toHaveBeenCalled(); - }); - - it("loginWeb waits for connection and closes", async () => { - const closeSpy = vi.fn(); - const ev = new EventEmitter(); - baileys.makeWASocket.mockImplementation(() => ({ - ev, - ws: { close: closeSpy }, - sendPresenceUpdate: vi.fn(), - sendMessage: vi.fn(), - })); - const waiter: typeof waitForWaConnection = vi - .fn() - .mockResolvedValue(undefined); - await loginWeb(false, waiter); - await new Promise((resolve) => setTimeout(resolve, 550)); - expect(closeSpy).toHaveBeenCalled(); - }); - - it("monitorWebInbox streams inbound messages", async () => { - const onMessage = vi.fn(async (msg) => { - await msg.sendComposing(); - await msg.reply("pong"); - }); - - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = getLastSocket(); - expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); - const upsert = { - type: "notify", - messages: [ - { - key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "ping" }, - messageTimestamp: 1_700_000_000, - pushName: "Tester", - }, - ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); - - expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ body: "ping", from: "+999", to: "+123" }), - ); - expect(sock.readMessages).toHaveBeenCalledWith([ - { - remoteJid: "999@s.whatsapp.net", - id: "abc", - participant: undefined, - fromMe: false, - }, - ]); - expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); - expect(sock.sendPresenceUpdate).toHaveBeenCalledWith( - "composing", - "999@s.whatsapp.net", - ); - expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { - text: "pong", - }); - - await listener.close(); - }); - - it("monitorWebInbox captures media path for image messages", async () => { - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = getLastSocket(); - const upsert = { - type: "notify", - messages: [ - { - key: { id: "med1", fromMe: false, remoteJid: "888@s.whatsapp.net" }, - message: { imageMessage: { mimetype: "image/jpeg" } }, - messageTimestamp: 1_700_000_100, - }, - ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); - - expect(onMessage).toHaveBeenCalledWith( - expect.objectContaining({ - body: "", - mediaPath: "/tmp/mid", - mediaType: "image/jpeg", - }), - ); - expect(sock.readMessages).toHaveBeenCalledWith([ - { - remoteJid: "888@s.whatsapp.net", - id: "med1", - participant: undefined, - fromMe: false, - }, - ]); - expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); - await listener.close(); - }); - - it("monitorWebInbox resolves onClose when the socket closes", async () => { - const listener = await monitorWebInbox({ - verbose: false, - onMessage: vi.fn(), - }); - const sock = getLastSocket(); - const reasonPromise = listener.onClose; - sock.ev.emit("connection.update", { - connection: "close", - lastDisconnect: { error: { output: { statusCode: 500 } } }, - }); - await expect(reasonPromise).resolves.toEqual( - expect.objectContaining({ status: 500, isLoggedOut: false }), - ); - await listener.close(); - }); - - it("monitorWebInbox logs inbound bodies to file", async () => { - const logPath = path.join( - os.tmpdir(), - `warelay-log-test-${crypto.randomUUID()}.log`, - ); - setLoggerOverride({ level: "trace", file: logPath }); - - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = getLastSocket(); - const upsert = { - type: "notify", - messages: [ - { - key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, - message: { conversation: "ping" }, - messageTimestamp: 1_700_000_000, - pushName: "Tester", - }, - ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); - - const content = fsSync.readFileSync(logPath, "utf-8"); - expect(content).toContain('"module":"web-inbound"'); - expect(content).toContain('"body":"ping"'); - await listener.close(); - }); - - it("monitorWebInbox includes participant when marking group messages read", async () => { - const onMessage = vi.fn(); - const listener = await monitorWebInbox({ verbose: false, onMessage }); - const sock = getLastSocket(); - const upsert = { - type: "notify", - messages: [ - { - key: { - id: "grp1", - fromMe: false, - remoteJid: "12345-67890@g.us", - participant: "111@s.whatsapp.net", - }, - message: { conversation: "group ping" }, - }, - ], - }; - - sock.ev.emit("messages.upsert", upsert); - await new Promise((resolve) => setImmediate(resolve)); - - expect(sock.readMessages).toHaveBeenCalledWith([ - { - remoteJid: "12345-67890@g.us", - id: "grp1", - participant: "111@s.whatsapp.net", - fromMe: false, - }, - ]); - await listener.close(); - }); - - it("monitorWebProvider reconnects after a connection close", async () => { + it("reconnects after a connection close", async () => { vi.useFakeTimers(); const closeResolvers: Array<() => void> = []; const listenerFactory = vi.fn(async () => { @@ -367,7 +67,7 @@ describe("provider-web", () => { await run; }); - it("monitorWebProvider falls back to text when media send fails", async () => { + it("falls back to text when media send fails", async () => { const sendMedia = vi.fn().mockRejectedValue(new Error("boom")); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); @@ -377,11 +77,11 @@ describe("provider-web", () => { }); let capturedOnMessage: - | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, + msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; @@ -438,18 +138,17 @@ describe("provider-web", () => { }); let capturedOnMessage: - | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, + msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; return { close: vi.fn() }; }; - // Create a large ( >5MB ) PNG to trigger compression. const bigPng = await sharp({ create: { width: 3200, @@ -540,7 +239,7 @@ describe("provider-web", () => { for (const fmt of formats) { // Force a small cap to ensure compression is exercised for every format. - loadConfigMock = () => ({ inbound: { reply: { mediaMaxMb: 1 } } }); + setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } })); const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); @@ -550,21 +249,19 @@ describe("provider-web", () => { }); let capturedOnMessage: - | (( - msg: import("./provider-web.js").WebInboundMessage, - ) => Promise) + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, + msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; return { close: vi.fn() }; }; - const width = 2000; - const height = 2000; + const width = 1200; + const height = 1200; const raw = crypto.randomBytes(width * height * 3); const big = await fmt.make(raw, { width, height }); expect(big.length).toBeGreaterThan(1 * 1024 * 1024); @@ -601,12 +298,13 @@ describe("provider-web", () => { expect(reply).not.toHaveBeenCalled(); fetchMock.mockRestore(); + resetLoadConfigMock(); } }, ); it("honors mediaMaxMb from config", async () => { - loadConfigMock = () => ({ inbound: { reply: { mediaMaxMb: 1 } } }); + setLoadConfigMock(() => ({ inbound: { reply: { mediaMaxMb: 1 } } })); const sendMedia = vi.fn(); const reply = vi.fn().mockResolvedValue(undefined); const sendComposing = vi.fn(); @@ -616,11 +314,11 @@ describe("provider-web", () => { }); let capturedOnMessage: - | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, + msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; @@ -687,11 +385,11 @@ describe("provider-web", () => { }); let capturedOnMessage: - | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, + msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; @@ -734,18 +432,15 @@ describe("provider-web", () => { }); it("logs outbound replies to file", async () => { - const logPath = path.join( - os.tmpdir(), - `warelay-log-test-${crypto.randomUUID()}.log`, - ); + const logPath = `/tmp/warelay-log-test-${crypto.randomUUID()}.log`; setLoggerOverride({ level: "trace", file: logPath }); let capturedOnMessage: - | ((msg: import("./provider-web.js").WebInboundMessage) => Promise) + | ((msg: import("./inbound.js").WebInboundMessage) => Promise) | undefined; const listenerFactory = async (opts: { onMessage: ( - msg: import("./provider-web.js").WebInboundMessage, + msg: import("./inbound.js").WebInboundMessage, ) => Promise; }) => { capturedOnMessage = opts.onMessage; @@ -766,30 +461,8 @@ describe("provider-web", () => { sendMedia: vi.fn(), }); - const content = fsSync.readFileSync(logPath, "utf-8"); + const content = await fs.readFile(logPath, "utf-8"); expect(content).toContain('"module":"web-auto-reply"'); expect(content).toContain('"text":"auto"'); }); - - it("logWebSelfId prints cached E.164 when creds exist", () => { - const existsSpy = vi - .spyOn(fsSync, "existsSync") - .mockReturnValue(true as never); - const readSpy = vi - .spyOn(fsSync, "readFileSync") - .mockReturnValue(JSON.stringify({ me: { id: "12345@s.whatsapp.net" } })); - const runtime = { - log: vi.fn(), - error: vi.fn(), - exit: vi.fn(), - }; - - logWebSelfId(runtime as never, true); - - expect(runtime.log).toHaveBeenCalledWith( - "Web Provider: +12345 (jid 12345@s.whatsapp.net)", - ); - existsSpy.mockRestore(); - readSpy.mockRestore(); - }); }); diff --git a/src/web/auto-reply.ts b/src/web/auto-reply.ts index 24c8c1e17..d75f70998 100644 --- a/src/web/auto-reply.ts +++ b/src/web/auto-reply.ts @@ -122,7 +122,8 @@ export async function monitorWebProvider( }); } else { const fileName = mediaUrl.split("/").pop() ?? "file"; - const mimetype = media.contentType ?? "application/octet-stream"; + const mimetype = + media.contentType ?? "application/octet-stream"; await msg.sendMedia({ document: media.buffer, fileName, diff --git a/src/web/login.test.ts b/src/web/login.test.ts new file mode 100644 index 000000000..d48d52e13 --- /dev/null +++ b/src/web/login.test.ts @@ -0,0 +1,42 @@ +import { EventEmitter } from "node:events"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + baileys, + resetBaileysMocks, + resetLoadConfigMock, +} from "./test-helpers.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { loginWeb } from "./login.js"; +import type { waitForWaConnection } from "./session.js"; + +describe("web login", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetBaileysMocks(); + resetLoadConfigMock(); + }); + + afterEach(() => { + resetLogger(); + setLoggerOverride(null); + }); + + it("loginWeb waits for connection and closes", async () => { + const closeSpy = vi.fn(); + const ev = new EventEmitter(); + baileys.makeWASocket.mockImplementation(() => ({ + ev, + ws: { close: closeSpy }, + sendPresenceUpdate: vi.fn(), + sendMessage: vi.fn(), + })); + const waiter: typeof waitForWaConnection = vi + .fn() + .mockResolvedValue(undefined); + await loginWeb(false, waiter); + await new Promise((resolve) => setTimeout(resolve, 550)); + expect(closeSpy).toHaveBeenCalled(); + }); +}); diff --git a/src/web/monitor-inbox.test.ts b/src/web/monitor-inbox.test.ts new file mode 100644 index 000000000..c69f8a13c --- /dev/null +++ b/src/web/monitor-inbox.test.ts @@ -0,0 +1,193 @@ +import crypto from "node:crypto"; +import fsSync from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + getLastSocket, + resetBaileysMocks, + resetLoadConfigMock, +} from "./test-helpers.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { monitorWebInbox } from "./inbound.js"; + +describe("web monitor inbox", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetBaileysMocks(); + resetLoadConfigMock(); + }); + + afterEach(() => { + resetLogger(); + setLoggerOverride(null); + vi.useRealTimers(); + }); + + it("streams inbound messages", async () => { + const onMessage = vi.fn(async (msg) => { + await msg.sendComposing(); + await msg.reply("pong"); + }); + + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = getLastSocket(); + expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); + const upsert = { + type: "notify", + messages: [ + { + key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "ping" }, + messageTimestamp: 1_700_000_000, + pushName: "Tester", + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ body: "ping", from: "+999", to: "+123" }), + ); + expect(sock.readMessages).toHaveBeenCalledWith([ + { + remoteJid: "999@s.whatsapp.net", + id: "abc", + participant: undefined, + fromMe: false, + }, + ]); + expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); + expect(sock.sendPresenceUpdate).toHaveBeenCalledWith( + "composing", + "999@s.whatsapp.net", + ); + expect(sock.sendMessage).toHaveBeenCalledWith("999@s.whatsapp.net", { + text: "pong", + }); + + await listener.close(); + }); + + it("captures media path for image messages", async () => { + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = getLastSocket(); + const upsert = { + type: "notify", + messages: [ + { + key: { id: "med1", fromMe: false, remoteJid: "888@s.whatsapp.net" }, + message: { imageMessage: { mimetype: "image/jpeg" } }, + messageTimestamp: 1_700_000_100, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(onMessage).toHaveBeenCalledWith( + expect.objectContaining({ + body: "", + mediaPath: "/tmp/mid", + mediaType: "image/jpeg", + }), + ); + expect(sock.readMessages).toHaveBeenCalledWith([ + { + remoteJid: "888@s.whatsapp.net", + id: "med1", + participant: undefined, + fromMe: false, + }, + ]); + expect(sock.sendPresenceUpdate).toHaveBeenCalledWith("available"); + await listener.close(); + }); + + it("resolves onClose when the socket closes", async () => { + const listener = await monitorWebInbox({ + verbose: false, + onMessage: vi.fn(), + }); + const sock = getLastSocket(); + const reasonPromise = listener.onClose; + sock.ev.emit("connection.update", { + connection: "close", + lastDisconnect: { error: { output: { statusCode: 500 } } }, + }); + await expect(reasonPromise).resolves.toEqual( + expect.objectContaining({ status: 500, isLoggedOut: false }), + ); + await listener.close(); + }); + + it("logs inbound bodies to file", async () => { + const logPath = path.join( + os.tmpdir(), + `warelay-log-test-${crypto.randomUUID()}.log`, + ); + setLoggerOverride({ level: "trace", file: logPath }); + + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = getLastSocket(); + const upsert = { + type: "notify", + messages: [ + { + key: { id: "abc", fromMe: false, remoteJid: "999@s.whatsapp.net" }, + message: { conversation: "ping" }, + messageTimestamp: 1_700_000_000, + pushName: "Tester", + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + const content = fsSync.readFileSync(logPath, "utf-8"); + expect(content).toContain('"module":"web-inbound"'); + expect(content).toContain('"body":"ping"'); + await listener.close(); + }); + + it("includes participant when marking group messages read", async () => { + const onMessage = vi.fn(); + const listener = await monitorWebInbox({ verbose: false, onMessage }); + const sock = getLastSocket(); + const upsert = { + type: "notify", + messages: [ + { + key: { + id: "grp1", + fromMe: false, + remoteJid: "12345-67890@g.us", + participant: "111@s.whatsapp.net", + }, + message: { conversation: "group ping" }, + }, + ], + }; + + sock.ev.emit("messages.upsert", upsert); + await new Promise((resolve) => setImmediate(resolve)); + + expect(sock.readMessages).toHaveBeenCalledWith([ + { + remoteJid: "12345-67890@g.us", + id: "grp1", + participant: "111@s.whatsapp.net", + fromMe: false, + }, + ]); + await listener.close(); + }); +}); diff --git a/src/web/outbound.test.ts b/src/web/outbound.test.ts new file mode 100644 index 000000000..6b7e78aef --- /dev/null +++ b/src/web/outbound.test.ts @@ -0,0 +1,29 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + getLastSocket, + resetBaileysMocks, + resetLoadConfigMock, +} from "./test-helpers.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { sendMessageWeb } from "./outbound.js"; + +describe("web outbound", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetBaileysMocks(); + resetLoadConfigMock(); + }); + + afterEach(() => { + resetLogger(); + setLoggerOverride(null); + }); + + it("sends message via web and closes socket", async () => { + await sendMessageWeb("+1555", "hi", { verbose: false }); + const sock = getLastSocket(); + expect(sock.sendMessage).toHaveBeenCalled(); + expect(sock.ws.close).toHaveBeenCalled(); + }); +}); diff --git a/src/web/session.test.ts b/src/web/session.test.ts new file mode 100644 index 000000000..acdc6fe64 --- /dev/null +++ b/src/web/session.test.ts @@ -0,0 +1,95 @@ +import { EventEmitter } from "node:events"; +import fsSync from "node:fs"; + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + baileys, + getLastSocket, + resetBaileysMocks, + resetLoadConfigMock, +} from "./test-helpers.js"; +import { resetLogger, setLoggerOverride } from "../logging.js"; +import { + createWaSocket, + logWebSelfId, + waitForWaConnection, +} from "./session.js"; + +describe("web session", () => { + beforeEach(() => { + vi.clearAllMocks(); + resetBaileysMocks(); + resetLoadConfigMock(); + }); + + afterEach(() => { + resetLogger(); + setLoggerOverride(null); + vi.useRealTimers(); + }); + + it("creates WA socket with QR handler", async () => { + await createWaSocket(true, false); + const makeWASocket = baileys.makeWASocket as ReturnType; + expect(makeWASocket).toHaveBeenCalledWith( + expect.objectContaining({ printQRInTerminal: false }), + ); + const passed = makeWASocket.mock.calls[0][0]; + const passedLogger = ( + passed as { logger?: { level?: string; trace?: unknown } } + ).logger; + expect(passedLogger?.level).toBe("silent"); + expect(typeof passedLogger?.trace).toBe("function"); + const sock = getLastSocket(); + const saveCreds = ( + await baileys.useMultiFileAuthState.mock.results[0].value + ).saveCreds; + // trigger creds.update listener + sock.ev.emit("creds.update", {}); + expect(saveCreds).toHaveBeenCalled(); + }); + + it("waits for connection open", async () => { + const ev = new EventEmitter(); + const promise = waitForWaConnection({ ev } as unknown as ReturnType< + typeof baileys.makeWASocket + >); + ev.emit("connection.update", { connection: "open" }); + await expect(promise).resolves.toBeUndefined(); + }); + + it("rejects when connection closes", async () => { + const ev = new EventEmitter(); + const promise = waitForWaConnection({ ev } as unknown as ReturnType< + typeof baileys.makeWASocket + >); + ev.emit("connection.update", { + connection: "close", + lastDisconnect: new Error("bye"), + }); + await expect(promise).rejects.toBeInstanceOf(Error); + }); + + it("logWebSelfId prints cached E.164 when creds exist", () => { + const existsSpy = vi + .spyOn(fsSync, "existsSync") + .mockReturnValue(true as never); + const readSpy = vi + .spyOn(fsSync, "readFileSync") + .mockReturnValue(JSON.stringify({ me: { id: "12345@s.whatsapp.net" } })); + const runtime = { + log: vi.fn(), + error: vi.fn(), + exit: vi.fn(), + }; + + logWebSelfId(runtime as never, true); + + expect(runtime.log).toHaveBeenCalledWith( + "Web Provider: +12345 (jid 12345@s.whatsapp.net)", + ); + existsSpy.mockRestore(); + readSpy.mockRestore(); + }); +}); diff --git a/src/web/test-helpers.ts b/src/web/test-helpers.ts new file mode 100644 index 000000000..2e5856b9d --- /dev/null +++ b/src/web/test-helpers.ts @@ -0,0 +1,78 @@ +import { vi } from "vitest"; + +import type { MockBaileysSocket } from "../../test/mocks/baileys.js"; +import { createMockBaileys } from "../../test/mocks/baileys.js"; + +let loadConfigMock: () => unknown = () => ({}); + +export function setLoadConfigMock(fn: () => unknown) { + loadConfigMock = fn; +} + +export function resetLoadConfigMock() { + loadConfigMock = () => ({}); +} + +vi.mock("../config/config.js", () => ({ + loadConfig: () => loadConfigMock(), +})); + +vi.mock("../media/store.js", () => ({ + saveMediaBuffer: vi + .fn() + .mockImplementation(async (_buf: Buffer, contentType?: string) => ({ + id: "mid", + path: "/tmp/mid", + size: _buf.length, + contentType, + })), +})); + +vi.mock("@whiskeysockets/baileys", () => { + const created = createMockBaileys(); + (globalThis as Record)[ + Symbol.for("warelay:lastSocket") + ] = created.lastSocket; + return created.mod; +}); + +vi.mock("qrcode-terminal", () => ({ + default: { generate: vi.fn() }, + generate: vi.fn(), +})); + +export const baileys = (await import( + "@whiskeysockets/baileys" +)) as unknown as typeof import("@whiskeysockets/baileys") & { + makeWASocket: ReturnType; + useMultiFileAuthState: ReturnType; + fetchLatestBaileysVersion: ReturnType; + makeCacheableSignalKeyStore: ReturnType; +}; + +export function resetBaileysMocks() { + const recreated = createMockBaileys(); + (globalThis as Record)[ + Symbol.for("warelay:lastSocket") + ] = recreated.lastSocket; + baileys.makeWASocket.mockImplementation(recreated.mod.makeWASocket); + baileys.useMultiFileAuthState.mockImplementation( + recreated.mod.useMultiFileAuthState, + ); + baileys.fetchLatestBaileysVersion.mockImplementation( + recreated.mod.fetchLatestBaileysVersion, + ); + baileys.makeCacheableSignalKeyStore.mockImplementation( + recreated.mod.makeCacheableSignalKeyStore, + ); +} + +export function getLastSocket(): MockBaileysSocket { + const getter = (globalThis as Record)[ + Symbol.for("warelay:lastSocket") + ]; + if (typeof getter === "function") + return (getter as () => MockBaileysSocket)(); + if (!getter) throw new Error("Baileys mock not initialized"); + throw new Error("Invalid Baileys socket getter"); +}