196 lines
7.6 KiB
TypeScript
196 lines
7.6 KiB
TypeScript
|
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||
|
|
|
||
|
|
vi.mock("~/lib/cloudinary.server", () => ({
|
||
|
|
verifyCloudinaryWebhookSignature: vi.fn(),
|
||
|
|
deleteCloudinaryImageByUrl: vi.fn().mockResolvedValue(undefined),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/user", () => ({
|
||
|
|
findUserById: vi.fn(),
|
||
|
|
updateUser: vi.fn(),
|
||
|
|
}));
|
||
|
|
vi.mock("~/models/team", () => ({
|
||
|
|
findTeamById: vi.fn(),
|
||
|
|
updateTeam: vi.fn(),
|
||
|
|
}));
|
||
|
|
vi.mock("~/lib/logger", () => ({
|
||
|
|
logger: { info: vi.fn(), error: vi.fn(), warn: vi.fn() },
|
||
|
|
}));
|
||
|
|
|
||
|
|
import { action } from "../api.webhooks.cloudinary";
|
||
|
|
import {
|
||
|
|
verifyCloudinaryWebhookSignature,
|
||
|
|
deleteCloudinaryImageByUrl,
|
||
|
|
} from "~/lib/cloudinary.server";
|
||
|
|
import { findUserById, updateUser } from "~/models/user";
|
||
|
|
import { findTeamById, updateTeam } from "~/models/team";
|
||
|
|
|
||
|
|
function makeRequest(body: object) {
|
||
|
|
return new Request("http://test/api/webhooks/cloudinary", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify(body),
|
||
|
|
headers: {
|
||
|
|
"Content-Type": "application/json",
|
||
|
|
"X-Cld-Signature": "sig",
|
||
|
|
"X-Cld-Timestamp": "1234567890",
|
||
|
|
},
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function approvedUserPayload(overrides: object = {}) {
|
||
|
|
return {
|
||
|
|
secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg",
|
||
|
|
moderation_status: "approved",
|
||
|
|
context: { avatar_target: "user", entity_id: "user-abc" },
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function approvedTeamPayload(overrides: object = {}) {
|
||
|
|
return {
|
||
|
|
secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/team-1.jpg",
|
||
|
|
moderation_status: "approved",
|
||
|
|
context: { avatar_target: "team", entity_id: "team-xyz" },
|
||
|
|
...overrides,
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
beforeEach(() => {
|
||
|
|
vi.clearAllMocks();
|
||
|
|
vi.mocked(verifyCloudinaryWebhookSignature).mockReturnValue(true);
|
||
|
|
vi.mocked(updateUser).mockResolvedValue({} as never);
|
||
|
|
vi.mocked(updateTeam).mockResolvedValue({} as never);
|
||
|
|
vi.mocked(findUserById).mockResolvedValue(null as never);
|
||
|
|
vi.mocked(findTeamById).mockResolvedValue(null as never);
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Cloudinary webhook — signature", () => {
|
||
|
|
it("returns 401 when signature is invalid", async () => {
|
||
|
|
vi.mocked(verifyCloudinaryWebhookSignature).mockReturnValue(false);
|
||
|
|
const res = await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never);
|
||
|
|
expect(res.status).toBe(401);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Cloudinary webhook — moderation", () => {
|
||
|
|
it("returns ignored when moderation_status is rejected", async () => {
|
||
|
|
const res = await action({
|
||
|
|
request: makeRequest({ ...approvedUserPayload(), moderation_status: "rejected" }),
|
||
|
|
params: {},
|
||
|
|
context: {},
|
||
|
|
} as never);
|
||
|
|
const body = await res.json();
|
||
|
|
expect(body).toEqual({ status: "ignored" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns ignored when moderation_status is pending", async () => {
|
||
|
|
const res = await action({
|
||
|
|
request: makeRequest({ ...approvedUserPayload(), moderation_status: "pending" }),
|
||
|
|
params: {},
|
||
|
|
context: {},
|
||
|
|
} as never);
|
||
|
|
const body = await res.json();
|
||
|
|
expect(body).toEqual({ status: "ignored" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("reads approval from nested moderation array", async () => {
|
||
|
|
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never);
|
||
|
|
const payload = {
|
||
|
|
secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg",
|
||
|
|
moderation: [{ status: "approved" }],
|
||
|
|
context: { avatar_target: "user", entity_id: "user-abc" },
|
||
|
|
};
|
||
|
|
const res = await action({ request: makeRequest(payload), params: {}, context: {} } as never);
|
||
|
|
const body = await res.json();
|
||
|
|
expect(body).toEqual({ status: "updated" });
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Cloudinary webhook — missing fields", () => {
|
||
|
|
it("returns 400 when secure_url is absent", async () => {
|
||
|
|
const res = await action({
|
||
|
|
request: makeRequest({ moderation_status: "approved", context: { avatar_target: "user", entity_id: "u1" } }),
|
||
|
|
params: {},
|
||
|
|
context: {},
|
||
|
|
} as never);
|
||
|
|
expect(res.status).toBe(400);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns 400 when context is absent", async () => {
|
||
|
|
const res = await action({
|
||
|
|
request: makeRequest({ moderation_status: "approved", secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/x.jpg" }),
|
||
|
|
params: {},
|
||
|
|
context: {},
|
||
|
|
} as never);
|
||
|
|
expect(res.status).toBe(400);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("returns 400 for unknown avatar_target", async () => {
|
||
|
|
const res = await action({
|
||
|
|
request: makeRequest({ ...approvedUserPayload(), context: { avatar_target: "league", entity_id: "l1" } }),
|
||
|
|
params: {},
|
||
|
|
context: {},
|
||
|
|
} as never);
|
||
|
|
expect(res.status).toBe(400);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Cloudinary webhook — user update", () => {
|
||
|
|
it("sets customAvatarUrl and avatarType on the user", async () => {
|
||
|
|
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never);
|
||
|
|
await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never);
|
||
|
|
expect(updateUser).toHaveBeenCalledWith("user-abc", {
|
||
|
|
customAvatarUrl: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg",
|
||
|
|
avatarType: "uploaded",
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("deletes the previous avatar when one exists", async () => {
|
||
|
|
const oldUrl = "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-old.jpg";
|
||
|
|
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: oldUrl } as never);
|
||
|
|
await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never);
|
||
|
|
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(oldUrl);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not call delete when there is no previous avatar", async () => {
|
||
|
|
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never);
|
||
|
|
await action({ request: makeRequest(approvedUserPayload()), params: {}, context: {} } as never);
|
||
|
|
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("does not delete when previous URL matches new URL", async () => {
|
||
|
|
const url = "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg";
|
||
|
|
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: url } as never);
|
||
|
|
await action({ request: makeRequest(approvedUserPayload({ secure_url: url })), params: {}, context: {} } as never);
|
||
|
|
expect(deleteCloudinaryImageByUrl).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
describe("Cloudinary webhook — team update", () => {
|
||
|
|
it("sets logoUrl and avatarType on the team", async () => {
|
||
|
|
vi.mocked(findTeamById).mockResolvedValue({ logoUrl: null } as never);
|
||
|
|
await action({ request: makeRequest(approvedTeamPayload()), params: {}, context: {} } as never);
|
||
|
|
expect(updateTeam).toHaveBeenCalledWith("team-xyz", {
|
||
|
|
logoUrl: "https://res.cloudinary.com/demo/image/upload/v1/avatars/team-1.jpg",
|
||
|
|
avatarType: "uploaded",
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
it("deletes the previous team logo when one exists", async () => {
|
||
|
|
const oldUrl = "https://res.cloudinary.com/demo/image/upload/v1/avatars/team-old.jpg";
|
||
|
|
vi.mocked(findTeamById).mockResolvedValue({ logoUrl: oldUrl } as never);
|
||
|
|
await action({ request: makeRequest(approvedTeamPayload()), params: {}, context: {} } as never);
|
||
|
|
expect(deleteCloudinaryImageByUrl).toHaveBeenCalledWith(oldUrl);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("also handles context in pipe-separated string format", async () => {
|
||
|
|
vi.mocked(findUserById).mockResolvedValue({ customAvatarUrl: null } as never);
|
||
|
|
const payload = {
|
||
|
|
secure_url: "https://res.cloudinary.com/demo/image/upload/v1/avatars/user-1.jpg",
|
||
|
|
moderation_status: "approved",
|
||
|
|
context: "avatar_target=user|entity_id=user-abc",
|
||
|
|
};
|
||
|
|
await action({ request: makeRequest(payload), params: {}, context: {} } as never);
|
||
|
|
expect(updateUser).toHaveBeenCalledWith("user-abc", expect.objectContaining({ avatarType: "uploaded" }));
|
||
|
|
});
|
||
|
|
});
|