Add CEO company branding endpoint

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-18 21:03:41 -05:00
parent 87b17de0bd
commit 7b4a4f45ed
8 changed files with 264 additions and 2 deletions

View File

@@ -25,6 +25,9 @@ vi.mock("../services/index.js", () => ({
budgetService: () => ({
upsertPolicy: vi.fn(),
}),
agentService: () => ({
getById: vi.fn(),
}),
logActivity: vi.fn(),
}));

View File

@@ -0,0 +1,197 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { companyRoutes } from "../routes/companies.js";
import { errorHandler } from "../middleware/index.js";
const mockCompanyService = vi.hoisted(() => ({
list: vi.fn(),
stats: vi.fn(),
getById: vi.fn(),
create: vi.fn(),
update: vi.fn(),
archive: vi.fn(),
remove: vi.fn(),
}));
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
ensureMembership: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({
upsertPolicy: vi.fn(),
}));
const mockCompanyPortabilityService = vi.hoisted(() => ({
exportBundle: vi.fn(),
previewImport: vi.fn(),
importBundle: vi.fn(),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
accessService: () => mockAccessService,
agentService: () => mockAgentService,
budgetService: () => mockBudgetService,
companyPortabilityService: () => mockCompanyPortabilityService,
companyService: () => mockCompanyService,
logActivity: mockLogActivity,
}));
function createCompany() {
const now = new Date("2026-03-19T02:00:00.000Z");
return {
id: "company-1",
name: "Paperclip",
description: null,
status: "active",
issuePrefix: "PAP",
issueCounter: 568,
budgetMonthlyCents: 0,
spentMonthlyCents: 0,
requireBoardApprovalForNewAgents: false,
brandColor: "#123456",
logoAssetId: "11111111-1111-4111-8111-111111111111",
logoUrl: "/api/assets/11111111-1111-4111-8111-111111111111/content",
createdAt: now,
updatedAt: now,
};
}
function createApp(actor: Record<string, unknown>) {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = actor;
next();
});
app.use("/api/companies", companyRoutes({} as any));
app.use(errorHandler);
return app;
}
describe("PATCH /api/companies/:companyId/branding", () => {
beforeEach(() => {
mockCompanyService.update.mockReset();
mockAgentService.getById.mockReset();
mockLogActivity.mockReset();
});
it("rejects non-CEO agent callers", async () => {
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "engineer",
});
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({ logoAssetId: "11111111-1111-4111-8111-111111111111" });
expect(res.status).toBe(403);
expect(res.body.error).toContain("Only CEO agents");
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
it("allows CEO agent callers to update branding fields", async () => {
const company = createCompany();
mockAgentService.getById.mockResolvedValue({
id: "agent-1",
companyId: "company-1",
role: "ceo",
});
mockCompanyService.update.mockResolvedValue(company);
const app = createApp({
type: "agent",
agentId: "agent-1",
companyId: "company-1",
source: "agent_key",
runId: "run-1",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({
logoAssetId: "11111111-1111-4111-8111-111111111111",
brandColor: "#123456",
});
expect(res.status).toBe(200);
expect(res.body.logoAssetId).toBe(company.logoAssetId);
expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", {
logoAssetId: "11111111-1111-4111-8111-111111111111",
brandColor: "#123456",
});
expect(mockLogActivity).toHaveBeenCalledWith(
expect.anything(),
expect.objectContaining({
companyId: "company-1",
actorType: "agent",
actorId: "agent-1",
agentId: "agent-1",
runId: "run-1",
action: "company.branding_updated",
details: {
logoAssetId: "11111111-1111-4111-8111-111111111111",
brandColor: "#123456",
},
}),
);
});
it("allows board callers to update branding fields", async () => {
const company = createCompany();
mockCompanyService.update.mockResolvedValue({
...company,
brandColor: null,
logoAssetId: null,
logoUrl: null,
});
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({ brandColor: null, logoAssetId: null });
expect(res.status).toBe(200);
expect(mockCompanyService.update).toHaveBeenCalledWith("company-1", {
brandColor: null,
logoAssetId: null,
});
});
it("rejects non-branding fields in the request body", async () => {
const app = createApp({
type: "board",
userId: "user-1",
source: "local_implicit",
});
const res = await request(app)
.patch("/api/companies/company-1/branding")
.send({
logoAssetId: "11111111-1111-4111-8111-111111111111",
status: "archived",
});
expect(res.status).toBe(400);
expect(res.body.error).toBe("Validation error");
expect(mockCompanyService.update).not.toHaveBeenCalled();
});
});