326 lines
12 KiB
TypeScript
326 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
|
|
|
|
const mocks = vi.hoisted(() => ({
|
|
tx: {
|
|
tenantUser: { findFirst: vi.fn() },
|
|
kontakt: { findFirst: vi.fn(), findMany: vi.fn(), update: vi.fn(), updateMany: vi.fn() },
|
|
kontaktEmail: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn() },
|
|
kontaktTelefon: { findFirst: vi.fn(), findMany: vi.fn(), updateMany: vi.fn(), deleteMany: vi.fn() },
|
|
kontaktRelation: { findFirst: vi.fn(), findMany: vi.fn(), create: vi.fn(), deleteMany: vi.fn() },
|
|
adresse: { findFirst: vi.fn() },
|
|
},
|
|
publishKontaktSearchUpsert: vi.fn(),
|
|
}));
|
|
|
|
vi.mock("~/utils/db/.server/tenantRls.server", () => ({
|
|
withTenantRLS: async (_tenantId: string, callback: (tx: typeof mocks.tx) => Promise<unknown>) => callback(mocks.tx),
|
|
}));
|
|
vi.mock("~/utils/db.server", () => ({ db: {} }));
|
|
vi.mock("~/services/search/searchIndexProjection.server", () => ({
|
|
publishKontaktSearchUpsert: mocks.publishKontaktSearchUpsert,
|
|
publishSearchProjectionDelete: vi.fn(),
|
|
}));
|
|
|
|
import {
|
|
addKontaktRelation,
|
|
bulkReassignKontakte,
|
|
CRM_BULK_MAX_CONTACTS,
|
|
getKontaktDetail,
|
|
getVisibleKontaktOrThrow,
|
|
listRelationsForKontakt,
|
|
listVisibleKontakteByIds,
|
|
normalizeBulkKontaktIds,
|
|
reassignKontakt,
|
|
removeKontaktEmail,
|
|
removeKontaktRelation,
|
|
removeKontaktTelefon,
|
|
setPrimaryEmail,
|
|
setPrimaryTelefon,
|
|
} from "./crmService.server";
|
|
|
|
describe("CRM assignee tenant boundary", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
mocks.tx.kontakt.findFirst.mockResolvedValue({ id: "contact-a" });
|
|
mocks.tx.kontakt.findMany.mockResolvedValue([]);
|
|
mocks.tx.kontakt.update.mockResolvedValue({ id: "contact-a" });
|
|
mocks.tx.kontakt.updateMany.mockResolvedValue({ count: 1 });
|
|
mocks.tx.kontaktEmail.findMany.mockResolvedValue([]);
|
|
mocks.tx.kontaktTelefon.findMany.mockResolvedValue([]);
|
|
mocks.tx.kontaktRelation.findMany.mockResolvedValue([]);
|
|
});
|
|
|
|
it("deduplicates bulk IDs and rejects payloads above the hard cap", () => {
|
|
expect(normalizeBulkKontaktIds([" contact-a ", "contact-a", "", "contact-b"])).toEqual(["contact-a", "contact-b"]);
|
|
expect(() => normalizeBulkKontaktIds(Array.from({ length: CRM_BULK_MAX_CONTACTS + 1 }, (_, index) => `contact-${index}`))).toThrow(
|
|
"foundation.mass_action_exceeds_limit"
|
|
);
|
|
});
|
|
|
|
it("batch-loads visible contacts once and preserves the requested order", async () => {
|
|
mocks.tx.kontakt.findMany.mockResolvedValue([
|
|
{ id: "contact-b", tenantId: "tenant-a" },
|
|
{ id: "contact-a", tenantId: "tenant-a" },
|
|
]);
|
|
|
|
await expect(
|
|
listVisibleKontakteByIds({
|
|
tenantId: "tenant-a",
|
|
ctx: { userId: "actor-a", permissions: ["crm.read_all"] },
|
|
kontaktIds: ["contact-a", "contact-b", "contact-a"],
|
|
})
|
|
).resolves.toEqual([
|
|
{ id: "contact-a", tenantId: "tenant-a" },
|
|
{ id: "contact-b", tenantId: "tenant-a" },
|
|
]);
|
|
|
|
expect(mocks.tx.kontakt.findMany).toHaveBeenCalledOnce();
|
|
expect(mocks.tx.kontakt.findMany).toHaveBeenCalledWith({
|
|
where: { id: { in: ["contact-a", "contact-b"] }, tenantId: "tenant-a", archivedAt: null },
|
|
});
|
|
});
|
|
|
|
it("rejects a foreign or inactive assignee before changing a contact", async () => {
|
|
mocks.tx.tenantUser.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(reassignKontakt({ tenantId: "tenant-a", kontaktId: "contact-a", zustaendigerUserId: "foreign-user" })).rejects.toMatchObject({ status: 404 });
|
|
|
|
expect(mocks.tx.tenantUser.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
tenantId: "tenant-a",
|
|
userId: "foreign-user",
|
|
status: TenantUserStatus.ACTIVE,
|
|
user: { active: true },
|
|
},
|
|
select: { id: true },
|
|
});
|
|
expect(mocks.tx.kontakt.findFirst).not.toHaveBeenCalled();
|
|
expect(mocks.tx.kontakt.update).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("applies the same active tenant membership check to bulk reassignment", async () => {
|
|
mocks.tx.tenantUser.findFirst.mockResolvedValue(null);
|
|
|
|
await expect(
|
|
bulkReassignKontakte({
|
|
tenantId: "tenant-a",
|
|
ctx: { userId: "actor-a", permissions: ["crm.read_all"] },
|
|
kontaktIds: ["contact-a"],
|
|
zustaendigerUserId: "foreign-user",
|
|
})
|
|
).rejects.toMatchObject({ status: 404 });
|
|
|
|
expect(mocks.tx.kontakt.updateMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("does not let a creator retain crm.read_own visibility after reassignment", async () => {
|
|
const reassignedContact = {
|
|
id: "contact-b",
|
|
tenantId: "tenant-a",
|
|
createdByUserId: "actor-a",
|
|
zustaendigerUserId: "actor-b",
|
|
};
|
|
mocks.tx.kontakt.findFirst.mockImplementation(async (args: { where?: { id?: string; tenantId?: string; zustaendigerUserId?: string } }) =>
|
|
args.where?.id === reassignedContact.id &&
|
|
args.where?.tenantId === reassignedContact.tenantId &&
|
|
args.where?.zustaendigerUserId === reassignedContact.zustaendigerUserId
|
|
? reassignedContact
|
|
: null
|
|
);
|
|
|
|
await expect(
|
|
getVisibleKontaktOrThrow("tenant-a", "contact-b", {
|
|
userId: "actor-a",
|
|
permissions: ["crm.read_own"],
|
|
})
|
|
).rejects.toMatchObject({ status: 404 });
|
|
|
|
expect(mocks.tx.kontakt.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "contact-b",
|
|
tenantId: "tenant-a",
|
|
zustaendigerUserId: "actor-a",
|
|
},
|
|
});
|
|
});
|
|
|
|
it("filters detail relations when the other contact is outside crm.read_own", async () => {
|
|
mocks.tx.kontakt.findFirst.mockResolvedValue({
|
|
id: "contact-a",
|
|
tenantId: "tenant-a",
|
|
adresseId: null,
|
|
zustaendigerUserId: "actor-a",
|
|
});
|
|
mocks.tx.kontaktRelation.findMany.mockResolvedValue([
|
|
{ id: "relation-a-b", vonKontaktId: "contact-a", zuKontaktId: "contact-b" },
|
|
{ id: "relation-c-a", vonKontaktId: "contact-c", zuKontaktId: "contact-a" },
|
|
]);
|
|
mocks.tx.kontakt.findMany.mockResolvedValue([{ id: "contact-b" }]);
|
|
|
|
const detail = await getKontaktDetail("tenant-a", "contact-a", {
|
|
userId: "actor-a",
|
|
permissions: ["crm.read_own"],
|
|
});
|
|
|
|
expect(detail.relationen).toEqual([{ id: "relation-a-b", vonKontaktId: "contact-a", zuKontaktId: "contact-b" }]);
|
|
expect(mocks.tx.kontakt.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: { in: ["contact-b", "contact-c"] },
|
|
tenantId: "tenant-a",
|
|
zustaendigerUserId: "actor-a",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("requires context and filters both relation directions in the direct relation API", async () => {
|
|
mocks.tx.kontaktRelation.findMany.mockResolvedValue([
|
|
{ id: "relation-a-b", vonKontaktId: "contact-a", zuKontaktId: "contact-b" },
|
|
{ id: "relation-c-a", vonKontaktId: "contact-c", zuKontaktId: "contact-a" },
|
|
]);
|
|
mocks.tx.kontakt.findMany.mockResolvedValue([{ id: "contact-c" }]);
|
|
|
|
await expect(
|
|
listRelationsForKontakt("tenant-a", "contact-a", {
|
|
userId: "actor-a",
|
|
permissions: ["crm.read_own"],
|
|
})
|
|
).resolves.toEqual([{ id: "relation-c-a", vonKontaktId: "contact-c", zuKontaktId: "contact-a" }]);
|
|
|
|
expect(mocks.tx.kontakt.findFirst).toHaveBeenCalledWith({
|
|
where: { id: "contact-a", tenantId: "tenant-a", zustaendigerUserId: "actor-a" },
|
|
});
|
|
expect(mocks.tx.kontakt.findMany).toHaveBeenCalledWith({
|
|
where: {
|
|
id: { in: ["contact-b", "contact-c"] },
|
|
tenantId: "tenant-a",
|
|
zustaendigerUserId: "actor-a",
|
|
},
|
|
select: { id: true },
|
|
});
|
|
});
|
|
|
|
it("returns 404 before listing relations when the requested contact is outside the caller scope", async () => {
|
|
mocks.tx.kontakt.findFirst.mockResolvedValueOnce(null);
|
|
|
|
await expect(
|
|
listRelationsForKontakt("tenant-a", "contact-from-tenant-b", {
|
|
userId: "actor-a",
|
|
permissions: ["crm.read_own"],
|
|
})
|
|
).rejects.toMatchObject({ status: 404 });
|
|
|
|
expect(mocks.tx.kontaktRelation.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects email IDs from another contact before changing or deleting them", async () => {
|
|
mocks.tx.kontaktEmail.findFirst.mockResolvedValue(null);
|
|
const input = {
|
|
tenantId: "tenant-a",
|
|
kontaktId: "contact-a",
|
|
ctx: { userId: "actor-a", permissions: ["crm.read_own"] },
|
|
emailId: "email-from-contact-b",
|
|
};
|
|
|
|
await expect(setPrimaryEmail(input)).rejects.toMatchObject({ status: 404 });
|
|
await expect(removeKontaktEmail(input)).rejects.toMatchObject({ status: 404 });
|
|
|
|
const scopedLookup = {
|
|
where: {
|
|
id: "email-from-contact-b",
|
|
tenantId: "tenant-a",
|
|
kontaktId: "contact-a",
|
|
},
|
|
select: { id: true, kontaktId: true },
|
|
};
|
|
expect(mocks.tx.kontaktEmail.findFirst).toHaveBeenNthCalledWith(1, scopedLookup);
|
|
expect(mocks.tx.kontaktEmail.findFirst).toHaveBeenNthCalledWith(2, scopedLookup);
|
|
expect(mocks.tx.kontaktEmail.updateMany).not.toHaveBeenCalled();
|
|
expect(mocks.tx.kontaktEmail.deleteMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects telephone IDs from another contact before changing or deleting them", async () => {
|
|
mocks.tx.kontaktTelefon.findFirst.mockResolvedValue(null);
|
|
const input = {
|
|
tenantId: "tenant-a",
|
|
kontaktId: "contact-a",
|
|
ctx: { userId: "actor-a", permissions: ["crm.read_own"] },
|
|
telefonId: "telephone-from-contact-b",
|
|
};
|
|
|
|
await expect(setPrimaryTelefon(input)).rejects.toMatchObject({ status: 404 });
|
|
await expect(removeKontaktTelefon(input)).rejects.toMatchObject({ status: 404 });
|
|
|
|
const scopedLookup = {
|
|
where: {
|
|
id: "telephone-from-contact-b",
|
|
tenantId: "tenant-a",
|
|
kontaktId: "contact-a",
|
|
},
|
|
select: { id: true, kontaktId: true },
|
|
};
|
|
expect(mocks.tx.kontaktTelefon.findFirst).toHaveBeenNthCalledWith(1, scopedLookup);
|
|
expect(mocks.tx.kontaktTelefon.findFirst).toHaveBeenNthCalledWith(2, scopedLookup);
|
|
expect(mocks.tx.kontaktTelefon.updateMany).not.toHaveBeenCalled();
|
|
expect(mocks.tx.kontaktTelefon.deleteMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects adding a relation when the target contact is not visible", async () => {
|
|
mocks.tx.kontakt.findFirst.mockImplementation(async (args: { where?: { id?: string; tenantId?: string; zustaendigerUserId?: string } }) =>
|
|
args.where?.id === "contact-a" && args.where?.tenantId === "tenant-a" && args.where?.zustaendigerUserId === "actor-a" ? { id: "contact-a" } : null
|
|
);
|
|
|
|
await expect(
|
|
addKontaktRelation({
|
|
tenantId: "tenant-a",
|
|
kontaktId: "contact-a",
|
|
ctx: { userId: "actor-a", permissions: ["crm.read_own"] },
|
|
zuKontaktId: "contact-b",
|
|
rolle: "hauptkontakt",
|
|
})
|
|
).rejects.toMatchObject({ status: 404 });
|
|
|
|
expect(mocks.tx.kontakt.findFirst).toHaveBeenNthCalledWith(1, {
|
|
where: { id: "contact-a", tenantId: "tenant-a", zustaendigerUserId: "actor-a" },
|
|
});
|
|
expect(mocks.tx.kontakt.findFirst).toHaveBeenNthCalledWith(2, {
|
|
where: { id: "contact-b", tenantId: "tenant-a", zustaendigerUserId: "actor-a" },
|
|
});
|
|
expect(mocks.tx.kontaktRelation.create).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("rejects removing a URL-contact relation when its other endpoint is not visible", async () => {
|
|
mocks.tx.kontakt.findFirst.mockImplementation(async (args: { where?: { id?: string; tenantId?: string; zustaendigerUserId?: string } }) =>
|
|
args.where?.id === "contact-a" && args.where?.tenantId === "tenant-a" && args.where?.zustaendigerUserId === "actor-a" ? { id: "contact-a" } : null
|
|
);
|
|
mocks.tx.kontaktRelation.findFirst.mockResolvedValue({
|
|
id: "relation-a-b",
|
|
vonKontaktId: "contact-a",
|
|
zuKontaktId: "contact-b",
|
|
});
|
|
|
|
await expect(
|
|
removeKontaktRelation({
|
|
tenantId: "tenant-a",
|
|
kontaktId: "contact-a",
|
|
ctx: { userId: "actor-a", permissions: ["crm.read_own"] },
|
|
relationId: "relation-a-b",
|
|
})
|
|
).rejects.toMatchObject({ status: 404 });
|
|
|
|
expect(mocks.tx.kontaktRelation.findFirst).toHaveBeenCalledWith({
|
|
where: {
|
|
id: "relation-a-b",
|
|
tenantId: "tenant-a",
|
|
OR: [{ vonKontaktId: "contact-a" }, { zuKontaktId: "contact-a" }],
|
|
},
|
|
select: { id: true, vonKontaktId: true, zuKontaktId: true },
|
|
});
|
|
expect(mocks.tx.kontakt.findFirst).toHaveBeenLastCalledWith({
|
|
where: { id: "contact-b", tenantId: "tenant-a", zustaendigerUserId: "actor-a" },
|
|
});
|
|
expect(mocks.tx.kontaktRelation.deleteMany).not.toHaveBeenCalled();
|
|
});
|
|
});
|