583 lines
22 KiB
TypeScript
583 lines
22 KiB
TypeScript
// @vitest-environment node
|
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
import { TenantUserJoined } from "~/application/enums/tenants/TenantUserJoined";
|
|
import { TenantUserStatus } from "~/application/enums/tenants/TenantUserStatus";
|
|
|
|
vi.mock("~/utils/db.server", () => ({
|
|
db: {
|
|
tenant: {
|
|
count: vi.fn(),
|
|
create: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
findMany: vi.fn(),
|
|
findUnique: vi.fn(),
|
|
update: vi.fn(),
|
|
delete: vi.fn(),
|
|
},
|
|
tenantUser: {
|
|
count: vi.fn(),
|
|
create: vi.fn(),
|
|
delete: vi.fn(),
|
|
deleteMany: vi.fn(),
|
|
findFirst: vi.fn(),
|
|
findFirstOrThrow: vi.fn(),
|
|
findMany: vi.fn(),
|
|
},
|
|
userRole: {
|
|
deleteMany: vi.fn(),
|
|
},
|
|
groupUser: {
|
|
deleteMany: vi.fn(),
|
|
},
|
|
$transaction: vi.fn(),
|
|
},
|
|
dbRead: {
|
|
tenant: {
|
|
findMany: vi.fn(),
|
|
},
|
|
},
|
|
}));
|
|
vi.mock("../helpers/RowFiltersHelper", () => ({
|
|
default: {
|
|
getFiltersCondition: vi.fn(),
|
|
},
|
|
}));
|
|
vi.mock("../cache.server", () => ({
|
|
cachified: vi.fn((options: { getFreshValue: () => unknown }) => options.getFreshValue()),
|
|
clearCacheKey: vi.fn(),
|
|
clearCacheKeyPrefix: vi.fn(),
|
|
}));
|
|
vi.mock("../services/emailService", () => ({
|
|
getAvailableTenantInboundAddress: vi.fn(),
|
|
}));
|
|
vi.mock("../services/tenantService", () => ({
|
|
getAvailableTenantSlug: vi.fn(),
|
|
}));
|
|
vi.mock("./permissions/userRoles.db.server", () => ({
|
|
createUserRole: vi.fn(),
|
|
}));
|
|
vi.mock("./tenantSubscriptions.db.server", () => ({
|
|
createTenantSubscription: vi.fn(),
|
|
}));
|
|
vi.mock("~/modules/plugins/services/.server/pluginHookPublisher", () => ({
|
|
emitPluginHookFireAndForget: vi.fn(),
|
|
}));
|
|
|
|
import { db, dbRead } from "~/utils/db.server";
|
|
import RowFiltersHelper from "../helpers/RowFiltersHelper";
|
|
import { cachified, clearCacheKey, clearCacheKeyPrefix } from "../cache.server";
|
|
import { getAvailableTenantInboundAddress } from "../services/emailService";
|
|
import { getAvailableTenantSlug } from "../services/tenantService";
|
|
import { createUserRole } from "./permissions/userRoles.db.server";
|
|
import { createTenantSubscription } from "./tenantSubscriptions.db.server";
|
|
import { emitPluginHookFireAndForget } from "~/modules/plugins/services/.server/pluginHookPublisher";
|
|
import {
|
|
addTenantTypeToTenant,
|
|
adminGetAllActiveTenantSimple,
|
|
adminGetAllTenants,
|
|
adminGetAllTenantsIdsAndNames,
|
|
adminGetAllTenantsWithUsage,
|
|
createTenant,
|
|
createTenantUser,
|
|
deleteTenant,
|
|
deleteTenantUser,
|
|
getAllTenantUsers,
|
|
getAllTenantsWithoutTypes,
|
|
getMyTenants,
|
|
getTenant,
|
|
getTenantByIdOrSlug,
|
|
getTenantBySlug,
|
|
getTenantMember,
|
|
getTenantSimple,
|
|
getTenantUser,
|
|
getTenantUserByIds,
|
|
getTenantUserSimple,
|
|
getTenantUserType,
|
|
getTenantUsers,
|
|
getTenantUsersCount,
|
|
getTenantUsersInTenantIds,
|
|
getTenantWithProfile,
|
|
getTenantWithUsers,
|
|
getTenantsInIds,
|
|
requireTenant,
|
|
updateTenant,
|
|
updateTenantDeactivated,
|
|
updateTenantTheme,
|
|
} from "./tenants.db.server";
|
|
|
|
const dbMock = vi.mocked(db);
|
|
const dbReadMock = vi.mocked(dbRead);
|
|
|
|
describe("tenants db wrapper", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.mocked(cachified).mockImplementation((async (options: { getFreshValue: () => unknown }) =>
|
|
options.getFreshValue()) as never);
|
|
dbMock.$transaction.mockImplementation((async (operations: Promise<unknown>[] | ((tx: typeof db) => Promise<unknown>)) => {
|
|
if (Array.isArray(operations)) {
|
|
return await Promise.all(operations);
|
|
}
|
|
return await operations(db);
|
|
}) as never);
|
|
vi.mocked(RowFiltersHelper.getFiltersCondition).mockReturnValue(undefined);
|
|
});
|
|
|
|
it("combines admin tenant usage filters for user membership, empty type, and pagination", async () => {
|
|
const baseWhere = { name: { contains: "acme" } };
|
|
vi.mocked(RowFiltersHelper.getFiltersCondition).mockReturnValue(baseWhere as never);
|
|
vi.mocked(dbMock.tenant.findMany).mockResolvedValueOnce([{ id: "tenant_1" }] as never);
|
|
vi.mocked(dbMock.tenant.count).mockResolvedValueOnce(42);
|
|
|
|
const result = await adminGetAllTenantsWithUsage(
|
|
{
|
|
query: "",
|
|
properties: [
|
|
{ name: "userId", value: "user_1" },
|
|
{ name: "typeId", value: "null" },
|
|
],
|
|
} as never,
|
|
{ page: 3, pageSize: 10 }
|
|
);
|
|
|
|
const userWhere = { OR: [baseWhere, { users: { some: { userId: "user_1" } } }] };
|
|
const expectedWhere = { OR: [userWhere, { types: { none: {} } }] };
|
|
expect(dbMock.tenant.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
skip: 20,
|
|
take: 10,
|
|
where: expectedWhere,
|
|
orderBy: { createdAt: "desc" },
|
|
})
|
|
);
|
|
expect(dbMock.tenant.count).toHaveBeenCalledWith({ where: expectedWhere });
|
|
expect(result.pagination).toEqual({ page: 3, pageSize: 10, totalItems: 42, totalPages: 5 });
|
|
});
|
|
|
|
it("uses read and write clients for the basic tenant listing helpers", async () => {
|
|
vi.mocked(dbMock.tenant.findMany).mockResolvedValue([] as never);
|
|
vi.mocked(dbReadMock.tenant.findMany).mockResolvedValue([] as never);
|
|
|
|
await getTenantsInIds(["tenant_1", "tenant_2"]);
|
|
await adminGetAllTenants();
|
|
await adminGetAllTenantsIdsAndNames();
|
|
await adminGetAllActiveTenantSimple();
|
|
|
|
expect(dbMock.tenant.findMany).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ where: { id: { in: ["tenant_1", "tenant_2"] } } })
|
|
);
|
|
expect(dbMock.tenant.findMany).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({ orderBy: { createdAt: "desc" } })
|
|
);
|
|
expect(dbReadMock.tenant.findMany).toHaveBeenNthCalledWith(1, {
|
|
select: { id: true, name: true, slug: true },
|
|
});
|
|
expect(dbReadMock.tenant.findMany).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
where: { active: true },
|
|
orderBy: { name: "asc" },
|
|
})
|
|
);
|
|
});
|
|
|
|
it("wraps cached tenant identity lookups with stable cache keys", async () => {
|
|
vi.mocked(dbMock.tenant.findUnique).mockResolvedValue({ id: "tenant_1" } as never);
|
|
vi.mocked(dbMock.tenant.findFirst).mockResolvedValue({ id: "tenant_1" } as never);
|
|
|
|
await getTenant("tenant_1");
|
|
await getTenantSimple("tenant_1");
|
|
await getTenantByIdOrSlug("acme");
|
|
|
|
expect(cachified).toHaveBeenCalledWith(expect.objectContaining({ key: "tenant:tenant_1" }));
|
|
expect(cachified).toHaveBeenCalledWith(expect.objectContaining({ key: "tenantSimple:tenant_1" }));
|
|
expect(cachified).toHaveBeenCalledWith(expect.objectContaining({ key: "tenantIdOrSlug:acme" }));
|
|
expect(dbMock.tenant.findFirst).toHaveBeenCalledWith({
|
|
where: { OR: [{ id: "acme" }, { slug: "acme" }] },
|
|
include: { subscription: true },
|
|
});
|
|
});
|
|
|
|
it("queries direct tenant identity variants without cache wrappers", async () => {
|
|
vi.mocked(dbMock.tenant.findUnique).mockResolvedValue({ id: "tenant_1" } as never);
|
|
|
|
await getTenantBySlug("acme");
|
|
await getTenantWithProfile("tenant_1");
|
|
await getTenantWithUsers("tenant_1");
|
|
|
|
expect(dbMock.tenant.findUnique).toHaveBeenNthCalledWith(1, { where: { slug: "acme" } });
|
|
expect(dbMock.tenant.findUnique).toHaveBeenNthCalledWith(2, { where: { id: "tenant_1" }, include: { types: true } });
|
|
expect(dbMock.tenant.findUnique).toHaveBeenNthCalledWith(3, { where: { id: "tenant_1" }, include: { users: true } });
|
|
});
|
|
|
|
it("uses the free-text query as a user filter and concrete tenant type when no base filter exists", async () => {
|
|
vi.mocked(dbMock.tenant.findMany).mockResolvedValueOnce([] as never);
|
|
vi.mocked(dbMock.tenant.count).mockResolvedValueOnce(3);
|
|
|
|
const result = await adminGetAllTenantsWithUsage({
|
|
query: "user_query",
|
|
properties: [{ name: "typeId", value: "type_pro" }],
|
|
} as never);
|
|
|
|
const expectedWhere = {
|
|
OR: [{ users: { some: { userId: "user_query" } } }, { types: { some: { id: "type_pro" } } }],
|
|
};
|
|
expect(dbMock.tenant.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
skip: undefined,
|
|
take: undefined,
|
|
where: expectedWhere,
|
|
})
|
|
);
|
|
expect(result.pagination).toEqual({ page: 1, pageSize: 10, totalItems: 3, totalPages: 1 });
|
|
});
|
|
|
|
it("throws a descriptive error when requireTenant cannot resolve a tenant", async () => {
|
|
vi.mocked(dbMock.tenant.findUnique).mockResolvedValueOnce(null);
|
|
|
|
await expect(requireTenant("tenant_missing")).rejects.toThrow("Tenant not found: tenant_missing");
|
|
});
|
|
|
|
it("returns null early for tenant-with-users and tenant-user lookups without ids", async () => {
|
|
await expect(getTenantWithUsers()).resolves.toBeNull();
|
|
await expect(getTenantUser()).resolves.toBeNull();
|
|
|
|
expect(dbMock.tenant.findUnique).not.toHaveBeenCalled();
|
|
expect(dbMock.tenantUser.findFirst).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("returns active member tenants and active tenant-user counts", async () => {
|
|
vi.mocked(dbMock.tenant.findMany).mockResolvedValueOnce([{ id: "tenant_1" }] as never);
|
|
vi.mocked(dbMock.tenantUser.count).mockResolvedValueOnce(7);
|
|
|
|
await expect(getMyTenants("user_1")).resolves.toEqual([{ id: "tenant_1" }]);
|
|
await expect(getTenantUsersCount("tenant_1")).resolves.toBe(7);
|
|
|
|
expect(dbMock.tenant.findMany).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
where: { active: true, users: { some: { userId: "user_1", status: TenantUserStatus.ACTIVE } } },
|
|
orderBy: { name: "asc" },
|
|
})
|
|
);
|
|
expect(dbMock.tenantUser.count).toHaveBeenCalledWith({
|
|
where: { tenantId: "tenant_1", status: TenantUserStatus.ACTIVE },
|
|
});
|
|
});
|
|
|
|
it("scopes tenant users to active members and tenant-local role includes by default", async () => {
|
|
vi.mocked(dbMock.tenantUser.findMany).mockResolvedValueOnce([{ id: "tu_1" }] as never);
|
|
|
|
await expect(getTenantUsers("tenant_1")).resolves.toEqual([{ id: "tu_1" }]);
|
|
|
|
expect(dbMock.tenantUser.findMany).toHaveBeenCalledWith({
|
|
where: { tenantId: "tenant_1", status: TenantUserStatus.ACTIVE },
|
|
include: {
|
|
user: {
|
|
include: {
|
|
roles: { where: { tenantId: "tenant_1" }, include: { role: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("skips the active-member status filter when inactive members are requested", async () => {
|
|
vi.mocked(dbMock.tenantUser.findMany).mockResolvedValueOnce([] as never);
|
|
|
|
await getTenantUsers("tenant_1", { includeInactive: true });
|
|
|
|
expect(dbMock.tenantUser.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { tenantId: "tenant_1" } }));
|
|
});
|
|
|
|
it("returns an empty member list without touching the database when tenant id is blank", async () => {
|
|
await expect(getTenantUsers(null)).resolves.toEqual([]);
|
|
|
|
expect(dbMock.tenantUser.findMany).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("adds tenant scope to tenant-user lookup only when a tenant id is provided", async () => {
|
|
vi.mocked(dbMock.tenantUser.findFirst).mockResolvedValue({ id: "tu_1" } as never);
|
|
|
|
await getTenantUser("tu_1");
|
|
await getTenantUser("tu_1", "tenant_1");
|
|
|
|
expect(dbMock.tenantUser.findFirst).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ where: { id: "tu_1" } })
|
|
);
|
|
expect(dbMock.tenantUser.findFirst).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({ where: { id: "tu_1", tenantId: "tenant_1" } })
|
|
);
|
|
});
|
|
|
|
it("queries bulk and admin tenant-user projections with tenant-safe includes", async () => {
|
|
vi.mocked(dbMock.tenantUser.findMany).mockResolvedValue([] as never);
|
|
|
|
await getTenantUsersInTenantIds(["tenant_1", "tenant_2"]);
|
|
await getAllTenantUsers();
|
|
|
|
expect(dbMock.tenantUser.findMany).toHaveBeenNthCalledWith(1, {
|
|
where: { tenantId: { in: ["tenant_1", "tenant_2"] } },
|
|
include: {
|
|
tenant: true,
|
|
user: { select: expect.any(Object) },
|
|
},
|
|
});
|
|
expect(dbMock.tenantUser.findMany).toHaveBeenNthCalledWith(2, {
|
|
include: {
|
|
user: {
|
|
include: {
|
|
admin: true,
|
|
roles: { include: { role: true } },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
it("queries tenant-user membership by composite identity and active member predicates", async () => {
|
|
vi.mocked(dbMock.tenantUser.findFirst).mockResolvedValue({ id: "tu_1" } as never);
|
|
|
|
await getTenantUserByIds("tenant_1", "user_1");
|
|
await getTenantMember("user_1", "tenant_1");
|
|
await getTenantUserType("user_1", "tenant_1");
|
|
|
|
expect(dbMock.tenantUser.findFirst).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({ where: { tenantId: "tenant_1", userId: "user_1" } })
|
|
);
|
|
expect(dbMock.tenantUser.findFirst).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.objectContaining({
|
|
where: { userId: "user_1", tenantId: "tenant_1", status: TenantUserStatus.ACTIVE, tenant: { active: true } },
|
|
})
|
|
);
|
|
expect(dbMock.tenantUser.findFirst).toHaveBeenNthCalledWith(3, {
|
|
where: { userId: "user_1", tenantId: "tenant_1", status: TenantUserStatus.ACTIVE, tenant: { active: true } },
|
|
select: { status: true, type: true },
|
|
});
|
|
});
|
|
|
|
it("caches tenant-user simple lookups and converts missing rows to null", async () => {
|
|
vi.mocked(dbMock.tenantUser.findFirstOrThrow).mockRejectedValueOnce(new Error("not found"));
|
|
|
|
await expect(getTenantUserSimple({ tenantId: "tenant_1", userId: "user_1" })).resolves.toBeNull();
|
|
|
|
expect(cachified).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
key: "tenantUserSimple:tenant_1:user_1",
|
|
ttl: 1000 * 60 * 60 * 24,
|
|
})
|
|
);
|
|
expect(dbMock.tenantUser.findFirstOrThrow).toHaveBeenCalledWith({
|
|
where: { tenantId: "tenant_1", userId: "user_1", status: TenantUserStatus.ACTIVE, tenant: { active: true } },
|
|
select: { id: true, type: true },
|
|
});
|
|
});
|
|
|
|
it("updates tenant profile data, tenant types, and all slug/id cache keys", async () => {
|
|
vi.mocked(dbMock.tenant.update).mockResolvedValueOnce({ id: "tenant_1", slug: "new-slug" } as never);
|
|
|
|
await updateTenant(
|
|
{ id: "tenant_1", slug: "old-slug" } as never,
|
|
{ name: "Acme", icon: "icon", slug: "new-slug", typeIds: ["type_1", "type_2"] }
|
|
);
|
|
|
|
expect(dbMock.tenant.update).toHaveBeenCalledWith({
|
|
where: { id: "tenant_1" },
|
|
data: {
|
|
name: "Acme",
|
|
icon: "icon",
|
|
slug: "new-slug",
|
|
types: { set: [{ id: "type_1" }, { id: "type_2" }] },
|
|
},
|
|
});
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenant:old-slug");
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenant:tenant_1");
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenantIdOrSlug:old-slug");
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenantSimple:tenant_1");
|
|
});
|
|
|
|
it("updates tenant theme and invalidates API-key tenant cache prefixes", async () => {
|
|
vi.mocked(dbMock.tenant.update).mockResolvedValueOnce({ id: "tenant_1", slug: "acme" } as never);
|
|
|
|
await updateTenantTheme("tenant_1", { theme: "dark", themeConfig: "{\"accent\":\"blue\"}" });
|
|
|
|
expect(dbMock.tenant.update).toHaveBeenCalledWith({
|
|
where: { id: "tenant_1" },
|
|
data: { theme: "dark", themeConfig: "{\"accent\":\"blue\"}" },
|
|
});
|
|
expect(clearCacheKeyPrefix).toHaveBeenCalledWith("apiKeyHash:");
|
|
});
|
|
|
|
it("adds a tenant type and clears tenant cache keys", async () => {
|
|
vi.mocked(dbMock.tenant.update).mockResolvedValueOnce({ id: "tenant_1", slug: "acme" } as never);
|
|
|
|
await addTenantTypeToTenant("tenant_1", { typeId: "type_1" });
|
|
|
|
expect(dbMock.tenant.update).toHaveBeenCalledWith({
|
|
where: { id: "tenant_1" },
|
|
data: { types: { connect: { id: "type_1" } } },
|
|
});
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenant:acme");
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenant:tenant_1");
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenantSimple:tenant_1");
|
|
});
|
|
|
|
it("creates tenants inactive when requested and emits the tenant-created plugin hook", async () => {
|
|
const createdAt = new Date("2026-05-25T12:00:00.000Z");
|
|
vi.mocked(getAvailableTenantSlug).mockResolvedValueOnce("acme");
|
|
vi.mocked(getAvailableTenantInboundAddress).mockResolvedValueOnce("acme@example.test");
|
|
vi.mocked(dbMock.tenant.create).mockResolvedValueOnce({ id: "tenant_1", slug: "acme", createdAt } as never);
|
|
|
|
await createTenant({ name: "Acme", icon: "icon", active: false, slug: "acme" });
|
|
|
|
expect(dbMock.tenant.create).toHaveBeenCalledWith({
|
|
data: {
|
|
name: "Acme",
|
|
slug: "acme",
|
|
icon: "icon",
|
|
active: false,
|
|
inboundAddresses: { create: [{ address: "acme@example.test" }] },
|
|
},
|
|
});
|
|
expect(createTenantSubscription).not.toHaveBeenCalled();
|
|
expect(emitPluginHookFireAndForget).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: "tenant.created" }),
|
|
{
|
|
tenantId: "tenant_1",
|
|
slug: "acme",
|
|
ownerEmail: null,
|
|
plan: null,
|
|
createdAt: "2026-05-25T12:00:00.000Z",
|
|
},
|
|
{ tenantId: "tenant_1", dispatchKey: "tenant:tenant_1:created" }
|
|
);
|
|
});
|
|
|
|
it("defaults new tenants active and creates a subscription when a customer id is present", async () => {
|
|
vi.mocked(getAvailableTenantSlug).mockResolvedValueOnce("acme");
|
|
vi.mocked(getAvailableTenantInboundAddress).mockResolvedValueOnce("acme@example.test");
|
|
vi.mocked(dbMock.tenant.create).mockResolvedValueOnce({
|
|
id: "tenant_1",
|
|
slug: "acme",
|
|
createdAt: new Date("2026-05-25T12:00:00.000Z"),
|
|
} as never);
|
|
|
|
await createTenant({ name: "Acme", subscriptionCustomerId: "cus_1" });
|
|
|
|
expect(dbMock.tenant.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ active: true }) }));
|
|
expect(createTenantSubscription).toHaveBeenCalledWith("tenant_1", "cus_1");
|
|
});
|
|
|
|
it("creates tenant users as active invited members and assigns each requested role", async () => {
|
|
vi.mocked(dbMock.tenantUser.create).mockResolvedValueOnce({ id: "tu_1", tenantId: "tenant_1", userId: "user_1" } as never);
|
|
|
|
await createTenantUser(
|
|
{ tenantId: "tenant_1", userId: "user_1", type: 0 },
|
|
[{ id: "role_admin" }, { id: "role_member" }] as never
|
|
);
|
|
|
|
expect(dbMock.tenantUser.create).toHaveBeenCalledWith({
|
|
data: {
|
|
tenantId: "tenant_1",
|
|
userId: "user_1",
|
|
type: 0,
|
|
joined: TenantUserJoined.JOINED_BY_INVITATION,
|
|
status: TenantUserStatus.ACTIVE,
|
|
},
|
|
});
|
|
expect(createUserRole).toHaveBeenCalledWith("user_1", "role_admin", "tenant_1");
|
|
expect(createUserRole).toHaveBeenCalledWith("user_1", "role_member", "tenant_1");
|
|
});
|
|
|
|
it("does not delete cross-tenant membership when a tenant-scoped delete misses", async () => {
|
|
vi.mocked(dbMock.tenantUser.findFirst).mockResolvedValueOnce(null);
|
|
|
|
await expect(deleteTenantUser("tu_1", "tenant_1")).resolves.toEqual({ count: 0 });
|
|
|
|
expect(dbMock.tenantUser.deleteMany).not.toHaveBeenCalled();
|
|
expect(dbMock.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("deletes tenant-scoped membership, roles, groups, and user-role cache together", async () => {
|
|
vi.mocked(dbMock.tenantUser.findFirst).mockResolvedValueOnce({ tenantId: "tenant_1", userId: "user_1" } as never);
|
|
vi.mocked(dbMock.tenantUser.deleteMany).mockResolvedValueOnce({ count: 1 } as never);
|
|
vi.mocked(dbMock.userRole.deleteMany).mockResolvedValueOnce({ count: 2 } as never);
|
|
vi.mocked(dbMock.groupUser.deleteMany).mockResolvedValueOnce({ count: 3 } as never);
|
|
|
|
await expect(deleteTenantUser("tu_1", "tenant_1")).resolves.toEqual({ count: 1 });
|
|
|
|
expect(dbMock.tenantUser.deleteMany).toHaveBeenCalledWith({ where: { id: "tu_1", tenantId: "tenant_1" } });
|
|
expect(dbMock.userRole.deleteMany).toHaveBeenCalledWith({ where: { tenantId: "tenant_1", userId: "user_1" } });
|
|
expect(dbMock.groupUser.deleteMany).toHaveBeenCalledWith({
|
|
where: { userId: "user_1", group: { tenantId: "tenant_1" } },
|
|
});
|
|
expect(clearCacheKey).toHaveBeenCalledWith("userRoles:user_1:tenant_1");
|
|
});
|
|
|
|
it("cleans role and group links after an unscoped tenant-user delete when the deleted row has a tenant", async () => {
|
|
vi.mocked(dbMock.tenantUser.delete).mockResolvedValueOnce({ id: "tu_1", tenantId: "tenant_1", userId: "user_1" } as never);
|
|
vi.mocked(dbMock.userRole.deleteMany).mockResolvedValueOnce({ count: 2 } as never);
|
|
vi.mocked(dbMock.groupUser.deleteMany).mockResolvedValueOnce({ count: 3 } as never);
|
|
|
|
await deleteTenantUser("tu_1");
|
|
|
|
expect(dbMock.$transaction).toHaveBeenCalledTimes(1);
|
|
expect(clearCacheKey).toHaveBeenCalledWith("userRoles:user_1:tenant_1");
|
|
});
|
|
|
|
it("returns an unscoped tenant-user delete without cleanup when the deleted row has no tenant", async () => {
|
|
vi.mocked(dbMock.tenantUser.delete).mockResolvedValueOnce({ id: "tu_1", tenantId: null, userId: "user_1" } as never);
|
|
|
|
await expect(deleteTenantUser("tu_1")).resolves.toEqual({ id: "tu_1", tenantId: null, userId: "user_1" });
|
|
|
|
expect(dbMock.$transaction).not.toHaveBeenCalled();
|
|
expect(clearCacheKey).not.toHaveBeenCalledWith("userRoles:user_1:null");
|
|
});
|
|
|
|
it("deletes tenants with dependent records included", async () => {
|
|
vi.mocked(dbMock.tenant.delete).mockResolvedValueOnce({ id: "tenant_1" } as never);
|
|
|
|
await deleteTenant("tenant_1");
|
|
|
|
expect(dbMock.tenant.delete).toHaveBeenCalledWith({
|
|
where: { id: "tenant_1" },
|
|
include: {
|
|
subscription: true,
|
|
users: true,
|
|
invitations: true,
|
|
rows: true,
|
|
logs: true,
|
|
apiKeys: true,
|
|
userRoles: true,
|
|
},
|
|
});
|
|
});
|
|
|
|
it("updates deactivation state and clears tenant identity cache keys", async () => {
|
|
vi.mocked(dbMock.tenant.update).mockResolvedValueOnce({ id: "tenant_1", slug: "acme" } as never);
|
|
|
|
await updateTenantDeactivated("tenant_1", { active: false, deactivatedReason: "billing" });
|
|
|
|
expect(dbMock.tenant.update).toHaveBeenCalledWith({
|
|
where: { id: "tenant_1" },
|
|
data: { active: false, deactivatedReason: "billing" },
|
|
});
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenant:acme");
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenant:tenant_1");
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenantIdOrSlug:acme");
|
|
expect(clearCacheKey).toHaveBeenCalledWith("tenantSimple:tenant_1");
|
|
});
|
|
|
|
it("lists tenants without assigned types", async () => {
|
|
vi.mocked(dbMock.tenant.findMany).mockResolvedValueOnce([] as never);
|
|
|
|
await getAllTenantsWithoutTypes();
|
|
|
|
expect(dbMock.tenant.findMany).toHaveBeenCalledWith({ where: { types: { none: {} } } });
|
|
});
|
|
});
|