Files
HiveGrid-Wireframes/hivegrid-cloud/apps/web/app/utils/db/tenants.db.server.test.ts
T

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: {} } } });
});
});