Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 22 additions & 6 deletions app/api/v1/cron/contact-avatars/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,29 @@ interface ContactRow {
id: string;
organization_id: string;
wa_identity: string | null;
wa_lid: string | null;
phone_number: string | null;
avatar_storage_path: string | null;
}

/** `lid:123…` / `phone:+55…` → o chatId que o adapter espera. */
function chatIdFromIdentity(identity: string): string | null {
if (identity.startsWith("lid:")) return `${identity.slice(4)}@lid`;
if (identity.startsWith("phone:")) return `${identity.slice(6).replace(/\D/g, "")}@c.us`;
/**
* Identidade do contato → o chatId que o adapter espera.
*
* MESMA ORDEM de `resolveWahaChatId` (lib/waha/send.ts) e de `chatIdOf`
* (session-reconciler): `wa_lid` primeiro, `wa_identity` depois, telefone por
* último. Esta função só lia `wa_identity` — que é GERADA com o telefone antes
* do lid (migration 0122). Num número BR cujo wa_id não tem o nono dígito, isso
* produzia `55AA9BBBBCCCC@c.us`, endereço inexistente: o provider devolvia
* `profilePictureURL: null`, o job carimbava "sem foto" e o avatar nunca vinha.
* O lid não depende do telefone, por isso vem na frente.
*/
function chatIdDoContato(c: ContactRow): string | null {
if (c.wa_lid) return `${c.wa_lid}@lid`;
if (c.wa_identity?.startsWith("lid:")) return `${c.wa_identity.slice(4)}@lid`;
if (c.wa_identity?.startsWith("phone:")) {
return `${c.wa_identity.slice(6).replace(/\D/g, "")}@c.us`;
}
if (c.phone_number) return `${c.phone_number.replace(/\D/g, "")}@c.us`;
return null;
}

Expand All @@ -75,7 +91,7 @@ async function handle(req: NextRequest): Promise<Response> {
// declarada irreversível no produto; esta linha é o que sustenta isso.
const { data: contatos, error: queryError } = await admin
.from("contacts")
.select("id, organization_id, wa_identity, avatar_storage_path")
.select("id, organization_id, wa_identity, wa_lid, phone_number, avatar_storage_path")
.not("wa_identity", "is", null)
.eq("is_anonymized", false)
.or(`avatar_updated_at.is.null,avatar_updated_at.lt.${cutoff}`)
Expand All @@ -93,7 +109,7 @@ async function handle(req: NextRequest): Promise<Response> {
let falhas = 0;

for (const c of rows) {
const chatId = c.wa_identity ? chatIdFromIdentity(c.wa_identity) : null;
const chatId = chatIdDoContato(c);
// Carimba mesmo sem conseguir resolver o chatId: sem isso o contato voltaria
// em TODA rodada do cron, para sempre, batendo no canal à toa.
//
Expand Down
156 changes: 156 additions & 0 deletions tests/unit/cron-contact-avatars-chatid.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
import { beforeEach, describe, expect, it, vi } from "vitest";

/**
* Qual endereço o cron de fotos pede ao canal.
*
* `wa_identity` é GERADA com o telefone antes do lid (migration 0122). Num número
* BR cujo `wa_id` não tem o nono dígito — comum em linhas antigas — derivar o
* chatId dela produz `55AA9BBBBCCCC@c.us`, endereço que não existe no WhatsApp:
* o provider responde `profilePictureURL: null`, o cron carimba "sem foto" e o
* avatar nunca aparece. Medido numa instalação real: `check-exists` do WAHA
* devolvia `{"numberExists":true,"chatId":"55AABBBBCCCC@c.us"}` (12 dígitos)
* para um contato cujo `wa_identity` dizia 13, e o `@lid` do mesmo contato
* devolvia a foto na hora.
*
* `wa_lid` não deriva de telefone, por isso vem primeiro — a MESMA ordem de
* `resolveWahaChatId` (lib/waha/send.ts) e de `chatIdOf` (session-reconciler).
*/

const CONTATO = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const ORG = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const LID = "142704667287623";

/** O contato do lote — cada teste ajusta antes de chamar. */
let linhaDoContato: Record<string, unknown> = {};
/** Endereços que o cron pediu ao canal. */
const pedidos: string[] = [];

vi.mock("@/lib/env", () => ({
env: { INTERNAL_CRON_SECRET: "segredo-de-teste", INTERNAL_SECRET: "segredo-de-teste" },
}));

vi.mock("@/lib/channels", () => ({
DEFAULT_CHANNEL_PROVIDER: "waha",
getAdapter: () => ({
fetchProfilePictureUrl: async (input: { recipient: string }) => {
pedidos.push(input.recipient);
return "https://cdn.exemplo.invalid/foto.jpg";
},
}),
}));

vi.mock("@/lib/supabase/admin", () => ({
createAdminClient: () => ({
from: (tabela: string) => ({
select: () => {
const dados =
tabela === "contacts"
? [linhaDoContato]
: { waha_session_name: "sessao-de-teste", provider: "waha" };
const proxy: Record<string, unknown> = new Proxy(
{},
{
get(_t, prop) {
if (prop === "then") {
return (ok: (v: unknown) => unknown) =>
Promise.resolve({ data: dados, error: null }).then(ok);
}
if (prop === "maybeSingle") return async () => ({ data: dados, error: null });
return () => proxy;
},
},
);
return proxy;
},
update: () => {
const proxy: Record<string, unknown> = new Proxy(
{},
{
get(_t, prop) {
if (prop === "then") {
return (ok: (v: unknown) => unknown) =>
Promise.resolve({ data: [{ id: CONTATO }], error: null }).then(ok);
}
if (prop === "select") {
return () => Promise.resolve({ data: [{ id: CONTATO }], error: null });
}
return () => proxy;
},
},
);
return proxy;
},
upsert: async () => ({ error: null }),
}),
storage: { from: () => ({ upload: async () => ({ error: null }) }) },
}),
}));

import { POST } from "@/app/api/v1/cron/contact-avatars/route";

beforeEach(() => {
pedidos.length = 0;
vi.stubGlobal(
"fetch",
vi.fn(async () => new Response(new Uint8Array([1, 2, 3]), { status: 200 })),
);
});

function chamar(): Promise<Response> {
return POST(
new Request("http://localhost/api/v1/cron/contact-avatars", {
method: "POST",
headers: { authorization: "Bearer segredo-de-teste" },
}) as never,
);
}

describe("cron de fotos: qual endereço vai ao canal", () => {
it("com wa_lid presente, pede pelo @lid — não pelo telefone do wa_identity", async () => {
linhaDoContato = {
id: CONTATO,
organization_id: ORG,
// O telefone tem o nono dígito; o wa_id real do contato não tem.
wa_identity: "phone:+5587999577575",
wa_lid: LID,
phone_number: "+5587999577575",
avatar_storage_path: null,
};

await chamar();

expect(pedidos).toEqual([`${LID}@lid`]);
// O endereço derivado do telefone é justamente o que não existe no WhatsApp.
expect(pedidos).not.toContain("5587999577575@c.us");
});

it("sem wa_lid, continua caindo no wa_identity — retaguarda preservada", async () => {
linhaDoContato = {
id: CONTATO,
organization_id: ORG,
wa_identity: "phone:+5511999990000",
wa_lid: null,
phone_number: "+5511999990000",
avatar_storage_path: null,
};

await chamar();

expect(pedidos).toEqual(["5511999990000@c.us"]);
});

it("wa_identity no formato lid: também resolve para @lid", async () => {
linhaDoContato = {
id: CONTATO,
organization_id: ORG,
wa_identity: `lid:${LID}`,
wa_lid: null,
phone_number: null,
avatar_storage_path: null,
};

await chamar();

expect(pedidos).toEqual([`${LID}@lid`]);
});
});
Loading