Files
Kordant/web/src/server/api/routers/user.test.ts
Michael Freno 40a9ef146c feat(billing): add subscription and Stripe billing router
- Add stripeCustomerId column to users table
- Create Stripe client initialization (web/src/server/stripe.ts)
- Add billing service with getOrCreateCustomer, checkout/portal sessions,
  subscription management, invoice listing, and webhook event handling
- Create billing tRPC router with getSubscription, createCheckoutSession,
  createPortalSession, cancelSubscription, reactivateSubscription, listInvoices
- Add raw webhook endpoint at /api/stripe/webhook with signature verification
- Define Valibot schemas for all billing procedure inputs
- Wire billing router into root tRPC router
- Update schema tests for new column/index counts
- Write unit tests for billing service and router
2026-05-25 16:07:00 -04:00

243 lines
8.9 KiB
TypeScript

import { describe, it, expect, vi, beforeEach } from "vitest";
import { initTRPC, TRPCError } from "@trpc/server";
import { wrap } from "@typeschema/valibot";
import { UpdateUserSchema, InviteMemberSchema, RemoveMemberSchema, UpdateRoleSchema } from "../schemas/user";
vi.mock("~/server/services/user.service", () => ({
getUserById: vi.fn(),
updateUser: vi.fn(),
deleteUser: vi.fn(),
}));
vi.mock("~/server/services/family.service", () => ({
getFamilyGroup: vi.fn(),
inviteMember: vi.fn(),
removeMember: vi.fn(),
updateMemberRole: vi.fn(),
}));
import { getUserById, updateUser, deleteUser } from "~/server/services/user.service";
import { getFamilyGroup, inviteMember, removeMember, updateMemberRole } from "~/server/services/family.service";
const mockGetUserById = vi.mocked(getUserById);
const mockUpdateUser = vi.mocked(updateUser);
const mockDeleteUser = vi.mocked(deleteUser);
const mockGetFamilyGroup = vi.mocked(getFamilyGroup);
const mockInviteMember = vi.mocked(inviteMember);
const mockRemoveMember = vi.mocked(removeMember);
const mockUpdateMemberRole = vi.mocked(updateMemberRole);
type User = {
id: string; email: string; name: string | null; image: string | null;
role: "user" | "family_admin" | "family_member" | "support"; emailVerified: Date | null; deletedAt: Date | null;
stripeCustomerId: string | null;
createdAt: Date; updatedAt: Date;
};
type Ctx = { db: object; user: User | null; apiKey: string | null };
function createCaller(user: User | null) {
const t = initTRPC.context<Ctx>().create();
const isAuthed = t.middleware(({ ctx, next }) => {
if (!ctx.user) throw new TRPCError({ code: "UNAUTHORIZED" });
return next({ ctx: { ...ctx, user: ctx.user } });
});
const router = t.router({
me: t.procedure.use(isAuthed).query(async ({ ctx }) => {
return mockGetUserById(ctx.user.id);
}),
update: t.procedure.use(isAuthed)
.input(wrap(UpdateUserSchema))
.mutation(async ({ ctx, input }) => {
return mockUpdateUser(ctx.user.id, input as Record<string, unknown>);
}),
delete: t.procedure.use(isAuthed)
.mutation(async ({ ctx }) => {
await mockDeleteUser(ctx.user.id);
return { success: true };
}),
listFamilyMembers: t.procedure.use(isAuthed)
.query(async ({ ctx }) => {
const group = await mockGetFamilyGroup(ctx.user.id);
return group.members;
}),
inviteFamilyMember: t.procedure.use(isAuthed)
.input(wrap(InviteMemberSchema))
.mutation(async ({ ctx, input }) => {
const group = await mockGetFamilyGroup(ctx.user.id);
const callerMember = group.members.find(
(m: { userId: string }) => m.userId === ctx.user.id,
);
if (!callerMember || (callerMember.role !== "owner" && callerMember.role !== "admin")) {
throw new TRPCError({ code: "FORBIDDEN" });
}
const i = input as { email: string; role: "admin" | "member" };
return mockInviteMember(group.id, i.email, ctx.user.id, i.role);
}),
removeFamilyMember: t.procedure.use(isAuthed)
.input(wrap(RemoveMemberSchema))
.mutation(async ({ ctx, input }) => {
const group = await mockGetFamilyGroup(ctx.user.id);
const i = input as { userId: string };
await mockRemoveMember(group.id, i.userId, ctx.user.id);
return { success: true };
}),
updateFamilyMemberRole: t.procedure.use(isAuthed)
.input(wrap(UpdateRoleSchema))
.mutation(async ({ ctx, input }) => {
const group = await mockGetFamilyGroup(ctx.user.id);
const i = input as { userId: string; role: "owner" | "admin" | "member" };
return mockUpdateMemberRole(group.id, i.userId, i.role, ctx.user.id);
}),
});
const caller = t.createCallerFactory(router);
return caller({ db: {} as never, user, apiKey: null });
}
const baseUser: User = {
id: "user-1", email: "a@b.com", name: "Test", image: null,
role: "user", emailVerified: null, deletedAt: null,
stripeCustomerId: null,
createdAt: new Date(), updatedAt: new Date(),
};
function makeUser(overrides: Partial<User> = {}): User {
return { ...baseUser, ...overrides };
}
const now = new Date();
beforeEach(() => {
vi.clearAllMocks();
});
describe("user.me", () => {
it("returns authenticated user", async () => {
mockGetUserById.mockResolvedValue({
...makeUser(), accounts: [], sessions: [], deviceTokens: [],
familyGroups: [], familyGroupOwned: [], subscriptions: [],
});
const api = createCaller(makeUser());
expect((await api.me()).id).toBe("user-1");
});
it("rejects unauthenticated", async () => {
const api = createCaller(null);
await expect(api.me()).rejects.toThrow(TRPCError);
});
});
describe("user.update", () => {
it("updates name", async () => {
mockUpdateUser.mockResolvedValue(makeUser({ name: "New" }));
const api = createCaller(makeUser());
expect((await api.update({ name: "New" })).name).toBe("New");
});
it("updates email", async () => {
mockUpdateUser.mockResolvedValue(makeUser({ email: "new@b.com" }));
const api = createCaller(makeUser());
expect((await api.update({ email: "new@b.com" })).email).toBe("new@b.com");
});
});
describe("user.delete", () => {
it("deletes user", async () => {
mockDeleteUser.mockResolvedValue(makeUser({ deletedAt: now }));
const api = createCaller(makeUser());
expect((await api.delete()).success).toBe(true);
});
});
describe("user.listFamilyMembers", () => {
it("returns family members", async () => {
mockGetFamilyGroup.mockResolvedValue({
id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now,
members: [{
id: "m1", groupId: "g1", userId: "u1", role: "owner",
joinedAt: now, createdAt: now, updatedAt: now,
user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User,
}],
owner: makeUser(),
});
const api = createCaller(makeUser());
const result = await api.listFamilyMembers();
expect(result).toHaveLength(1);
expect(result[0].role).toBe("owner");
});
});
describe("user.inviteFamilyMember", () => {
it("creates invitation", async () => {
mockGetFamilyGroup.mockResolvedValue({
id: "g1", name: "Fam", ownerId: "user-1", createdAt: now, updatedAt: now,
members: [{
id: "m1", groupId: "g1", userId: "user-1", role: "owner",
joinedAt: now, createdAt: now, updatedAt: now,
user: { id: "user-1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User,
}],
owner: makeUser(),
});
mockInviteMember.mockResolvedValue({
id: "i1", groupId: "g1", email: "new@b.com", role: "member",
invitedBy: "user-1", status: "pending", expiresAt: now, createdAt: now, updatedAt: now,
});
const api = createCaller(makeUser());
expect((await api.inviteFamilyMember({ email: "new@b.com" })).status).toBe("pending");
});
it("rejects non-admin", async () => {
mockGetFamilyGroup.mockResolvedValue({
id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now,
members: [{
id: "m1", groupId: "g1", userId: "u2", role: "member",
joinedAt: now, createdAt: now, updatedAt: now,
user: { id: "u2", email: "b@b.com", name: "B", image: null, role: "user" } as unknown as User,
}],
owner: makeUser(),
});
const api = createCaller(makeUser({ id: "u2" }));
await expect(api.inviteFamilyMember({ email: "new@b.com" })).rejects.toThrow(TRPCError);
});
});
describe("user.removeFamilyMember", () => {
it("removes a member", async () => {
mockGetFamilyGroup.mockResolvedValue({
id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now,
members: [{
id: "m1", groupId: "g1", userId: "u1", role: "owner",
joinedAt: now, createdAt: now, updatedAt: now,
user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User,
}],
owner: makeUser(),
});
mockRemoveMember.mockResolvedValue(undefined);
const api = createCaller(makeUser());
expect((await api.removeFamilyMember({ userId: "u3" })).success).toBe(true);
});
});
describe("user.updateFamilyMemberRole", () => {
it("updates role", async () => {
mockGetFamilyGroup.mockResolvedValue({
id: "g1", name: "Fam", ownerId: "u1", createdAt: now, updatedAt: now,
members: [{
id: "m1", groupId: "g1", userId: "u1", role: "owner",
joinedAt: now, createdAt: now, updatedAt: now,
user: { id: "u1", email: "a@b.com", name: "A", image: null, role: "user" } as unknown as User,
}],
owner: makeUser(),
});
mockUpdateMemberRole.mockResolvedValue({
id: "m2", groupId: "g1", userId: "u2", role: "admin",
joinedAt: now, createdAt: now, updatedAt: now,
});
const api = createCaller(makeUser());
expect(
(await api.updateFamilyMemberRole({ userId: "u2", role: "admin" })).role,
).toBe("admin");
});
});