All checks were successful
PR checks / checks (pull_request) Successful in 31s
Companies can upload a logo and a stamp/seal (Admin → Companies → Edit → Branding); both render on exported PDF and XLSX purchase orders. A fixed brand-colour bar (#92D050, matching the sample PO) runs along the bottom of every export. - Company.logoKey / stampKey + migration - buildCompanyAssetKey() deterministic storage keys (overwrite-in-place) - uploadCompanyAsset / removeCompanyAsset server actions (≤4MB PNG/JPG/WebP, manage_vessels_accounts gated) - CompanyBrandingUploader in the company edit dialog with live previews - Export route embeds logo (top-left), stamp (signatory block) and brand bar in both ExcelJS and print-HTML paths - Unit test (storage keys) + integration test (branding actions) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
114 lines
4.6 KiB
TypeScript
114 lines
4.6 KiB
TypeScript
/**
|
|
* Integration tests for company branding actions (logo + stamp uploads).
|
|
* Covers:
|
|
* - Manager can upload a logo / stamp; the key is stored on the company
|
|
* - Re-upload overwrites in place (deterministic key)
|
|
* - Invalid asset type, bad mime, and oversize files are rejected
|
|
* - removeCompanyAsset clears the key
|
|
* - Permission gating (TECHNICAL cannot manage branding)
|
|
*/
|
|
import { vi, describe, it, expect, beforeAll, afterAll } from "vitest";
|
|
|
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
|
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
|
vi.mock("@/lib/storage", async (importOriginal) => ({
|
|
...(await importOriginal<typeof import("@/lib/storage")>()),
|
|
uploadBuffer: vi.fn(), // don't touch the filesystem in tests
|
|
}));
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { uploadBuffer } from "@/lib/storage";
|
|
import { uploadCompanyAsset, removeCompanyAsset } from "@/app/(portal)/admin/companies/actions";
|
|
import { makeSession } from "./helpers";
|
|
|
|
const mockedAuth = vi.mocked(auth);
|
|
const mockedUpload = vi.mocked(uploadBuffer);
|
|
|
|
let companyId: string;
|
|
|
|
function pngFile(name: string, bytes = 1024): File {
|
|
return new File([new Uint8Array(bytes)], name, { type: "image/png" });
|
|
}
|
|
|
|
function assetForm(id: string, type: string, file: File): FormData {
|
|
const form = new FormData();
|
|
form.set("companyId", id);
|
|
form.set("type", type);
|
|
form.set("file", file);
|
|
return form;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const company = await db.company.create({
|
|
data: { name: "INTTEST_BRANDING_CO", code: "ZZBRAND" },
|
|
});
|
|
companyId = company.id;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.company.delete({ where: { id: companyId } }).catch(() => {});
|
|
});
|
|
|
|
describe("uploadCompanyAsset", () => {
|
|
it("stores a logo key on the company", async () => {
|
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
|
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pngFile("logo.png")));
|
|
expect(res).toEqual({ ok: true });
|
|
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
|
|
expect(c.logoKey).toBe(`company-assets/${companyId}/logo.png`);
|
|
expect(mockedUpload).toHaveBeenCalled();
|
|
});
|
|
|
|
it("stores a stamp key independently of the logo", async () => {
|
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
|
const res = await uploadCompanyAsset(assetForm(companyId, "stamp", pngFile("stamp.png")));
|
|
expect(res).toEqual({ ok: true });
|
|
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
|
|
expect(c.stampKey).toBe(`company-assets/${companyId}/stamp.png`);
|
|
expect(c.logoKey).toBe(`company-assets/${companyId}/logo.png`);
|
|
});
|
|
|
|
it("rejects an unknown asset type", async () => {
|
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
|
const res = await uploadCompanyAsset(assetForm(companyId, "header", pngFile("x.png")));
|
|
expect(res).toEqual({ error: "Invalid asset type" });
|
|
});
|
|
|
|
it("rejects a non-image mime type", async () => {
|
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
|
const pdf = new File([new Uint8Array(10)], "x.pdf", { type: "application/pdf" });
|
|
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pdf));
|
|
expect(res).toEqual({ error: "Image must be a PNG, JPG, or WebP" });
|
|
});
|
|
|
|
it("rejects a file over 4 MB", async () => {
|
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
|
const big = pngFile("big.png", 5 * 1024 * 1024);
|
|
const res = await uploadCompanyAsset(assetForm(companyId, "logo", big));
|
|
expect(res).toEqual({ error: "Image must be under 4 MB" });
|
|
});
|
|
|
|
it("refuses callers without manage_vessels_accounts", async () => {
|
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
|
const res = await uploadCompanyAsset(assetForm(companyId, "logo", pngFile("logo.png")));
|
|
expect(res).toEqual({ error: "Unauthorized" });
|
|
});
|
|
});
|
|
|
|
describe("removeCompanyAsset", () => {
|
|
it("clears the stored key", async () => {
|
|
mockedAuth.mockResolvedValue(makeSession("u-mgr", "MANAGER") as never);
|
|
const res = await removeCompanyAsset(companyId, "logo");
|
|
expect(res).toEqual({ ok: true });
|
|
const c = await db.company.findUniqueOrThrow({ where: { id: companyId } });
|
|
expect(c.logoKey).toBeNull();
|
|
expect(c.stampKey).toBe(`company-assets/${companyId}/stamp.png`); // stamp untouched
|
|
});
|
|
|
|
it("refuses unauthorized callers", async () => {
|
|
mockedAuth.mockResolvedValue(makeSession("u-tech", "TECHNICAL") as never);
|
|
const res = await removeCompanyAsset(companyId, "stamp");
|
|
expect(res).toEqual({ error: "Unauthorized" });
|
|
});
|
|
});
|