feat: basic onboarding flow\
This commit is contained in:
@@ -6,17 +6,19 @@ model User {
|
||||
email String @unique
|
||||
emailVerified Boolean @default(false)
|
||||
image String?
|
||||
onboardingCompleted Boolean @default(false)
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
passkeys Passkey[]
|
||||
groupsMember GroupMember[]
|
||||
groupsOwner Group[] @relation("OwnerGroups")
|
||||
orgMemberships Member[]
|
||||
orgInvitations Invitation[] @relation("InvitedBy")
|
||||
waitlist WaitlistEntry[]
|
||||
accounts Account[]
|
||||
sessions Session[]
|
||||
passkeys Passkey[]
|
||||
groupsMember GroupMember[]
|
||||
groupsOwner Group[] @relation("OwnerGroups")
|
||||
orgMemberships Member[]
|
||||
orgInvitations Invitation[] @relation("InvitedBy")
|
||||
waitlist WaitlistEntry[]
|
||||
merchantAccount MerchantAccount?
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
// Domain models
|
||||
|
||||
model Group {
|
||||
id String @id @default(cuid())
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
description String?
|
||||
days WeekDay[]
|
||||
time String?
|
||||
capacity Int?
|
||||
price Decimal? @db.Decimal(10, 2)
|
||||
billingType BillingType?
|
||||
dueDay Int?
|
||||
createdById String
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
owner User @relation("OwnerGroups", fields: [createdById], references: [id], onDelete: Cascade)
|
||||
members GroupMember[]
|
||||
@@ -16,6 +22,21 @@ model Group {
|
||||
@@map("groups")
|
||||
}
|
||||
|
||||
enum WeekDay {
|
||||
MONDAY
|
||||
TUESDAY
|
||||
WEDNESDAY
|
||||
THURSDAY
|
||||
FRIDAY
|
||||
SATURDAY
|
||||
SUNDAY
|
||||
}
|
||||
|
||||
enum BillingType {
|
||||
MONTHLY
|
||||
PER_CLASS
|
||||
}
|
||||
|
||||
model GroupMember {
|
||||
id String @id @default(cuid())
|
||||
groupId String
|
||||
@@ -103,4 +124,24 @@ enum WaitlistStatus {
|
||||
INVITED
|
||||
JOINED
|
||||
DECLINED
|
||||
}
|
||||
|
||||
enum PaymentProvider {
|
||||
MERCADO_PAGO
|
||||
STRIPE
|
||||
}
|
||||
|
||||
model MerchantAccount {
|
||||
id String @id @default(cuid())
|
||||
userId String @unique
|
||||
provider PaymentProvider
|
||||
accessToken String?
|
||||
sandbox Boolean @default(true)
|
||||
connectedAt DateTime @default(now())
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@map("merchant_accounts")
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import { logger } from '@/logger';
|
||||
import { authRoutes } from './modules/auth';
|
||||
import { groupsRoutes } from './modules/groups';
|
||||
import { healthCheckRoutes } from './modules/health-check';
|
||||
import { onboardingRoutes } from './modules/onboarding';
|
||||
import { paymentsRoutes } from './modules/payments';
|
||||
import { studentsRoutes } from './modules/students';
|
||||
import { waitlistRoutes } from './modules/waitlist';
|
||||
@@ -33,6 +34,7 @@ api.use('*', sessionAuthMiddleware);
|
||||
api.route('/auth', authRoutes);
|
||||
api.route('/health', healthCheckRoutes);
|
||||
api.route('/groups', groupsRoutes);
|
||||
api.route('/onboarding', onboardingRoutes);
|
||||
api.route('/students', studentsRoutes);
|
||||
api.route('/payments', paymentsRoutes);
|
||||
api.route('/waitlist', waitlistRoutes);
|
||||
|
||||
@@ -86,4 +86,18 @@ export function noGroupAccessProblem(): ProblemDetails {
|
||||
|
||||
export function notFoundResourceProblem(resourceName: string, id: string): ProblemDetails {
|
||||
return notFoundProblem(`${resourceName} ${id}`);
|
||||
}
|
||||
|
||||
export function paymentNotSetupProblem(): ProblemDetails {
|
||||
return conflictProblem({
|
||||
detail: 'Vinculá una cuenta de cobro antes de crear tu primer grupo.',
|
||||
code: 'payment_not_setup',
|
||||
});
|
||||
}
|
||||
|
||||
export function onboardingAlreadyCompletedProblem(): ProblemDetails {
|
||||
return conflictProblem({
|
||||
detail: 'Ya completaste el onboarding de Gruperly.',
|
||||
code: 'onboarding_already_completed',
|
||||
});
|
||||
}
|
||||
@@ -12,22 +12,13 @@ import {
|
||||
organizationNotFoundProblem,
|
||||
} from '@/http/problem-builders';
|
||||
import { default as prisma, UnitOfWork } from '@/lib/prisma';
|
||||
import { type GroupDb, toGroupDto } from '../../lib';
|
||||
import { type GroupDb, type GroupRecord, toGroupDto } from '../../lib';
|
||||
|
||||
type CreateGroupFromOrganizationDeps = {
|
||||
db?: Pick<GroupDb, 'group' | 'groupMember' | 'organization'>;
|
||||
unitOfWork?: UnitOfWork;
|
||||
};
|
||||
|
||||
type GroupRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export class CreateGroupFromOrganization {
|
||||
constructor(private readonly deps: CreateGroupFromOrganizationDeps = {}) {}
|
||||
|
||||
|
||||
@@ -8,21 +8,17 @@ import type {
|
||||
import { ok } from '@gruperly/shared';
|
||||
import { getPaginationMetadata, getPaginationOffset } from '@/lib/pagination';
|
||||
import prisma from '@/lib/prisma';
|
||||
import { buildGroupWhereForUser, type GroupDb, toGroupDto } from '../../lib';
|
||||
import {
|
||||
buildGroupWhereForUser,
|
||||
type GroupDb,
|
||||
type GroupRecord,
|
||||
toGroupDto,
|
||||
} from '../../lib';
|
||||
|
||||
type ListGroupsDeps = {
|
||||
db?: Pick<GroupDb, 'group'>;
|
||||
};
|
||||
|
||||
type GroupRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
};
|
||||
|
||||
export class ListGroups {
|
||||
constructor(private readonly deps: ListGroupsDeps = {}) {}
|
||||
|
||||
|
||||
@@ -1,15 +1,23 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { GroupDto } from '@gruperly/shared';
|
||||
import type { BillingType, GroupDto, WeekDay } from '@gruperly/shared';
|
||||
|
||||
export type GroupDb = Pick<PrismaClient, 'group' | 'groupMember' | 'organization'>;
|
||||
|
||||
type GroupRecord = {
|
||||
type PriceLike = { toString(): string };
|
||||
|
||||
export type GroupRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
days: WeekDay[] | null;
|
||||
time: string | null;
|
||||
capacity: number | null;
|
||||
price: PriceLike | number | null;
|
||||
billingType: BillingType | null;
|
||||
dueDay: number | null;
|
||||
};
|
||||
|
||||
export function toGroupDto(record: GroupRecord): GroupDto {
|
||||
@@ -20,6 +28,12 @@ export function toGroupDto(record: GroupRecord): GroupDto {
|
||||
createdById: record.createdById,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
days: record.days,
|
||||
time: record.time,
|
||||
capacity: record.capacity,
|
||||
price: record.price == null ? null : Number(record.price.toString()),
|
||||
billingType: record.billingType,
|
||||
dueDay: record.dueDay,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { CreateFirstGroup } from '@gruperly/shared';
|
||||
import { CreateFirstGroupSchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { CreateFirstGroup as CreateFirstGroupUseCase } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/', validate.json(CreateFirstGroupSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
|
||||
const data = c.req.valid('json') as CreateFirstGroup;
|
||||
const useCase = new CreateFirstGroupUseCase();
|
||||
const result = await useCase.execute(data, user.id);
|
||||
return resultJson(c, result, { status: 201 });
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Prisma, PrismaClient } from '@generated/prisma/client';
|
||||
import { Role } from '@generated/prisma/client';
|
||||
import type {
|
||||
CreateFirstGroup as CreateFirstGroupInput,
|
||||
CreateFirstGroupResult,
|
||||
ProblemDetails,
|
||||
Result,
|
||||
} from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import {
|
||||
onboardingAlreadyCompletedProblem,
|
||||
paymentNotSetupProblem,
|
||||
} from '@/http/problem-builders';
|
||||
import { problemDetails } from '@/http/problem-details';
|
||||
import { default as prisma, UnitOfWork } from '@/lib/prisma';
|
||||
import { toOnboardingGroupDto } from '../../lib';
|
||||
|
||||
type CreateFirstGroupDeps = {
|
||||
db?: Pick<PrismaClient, 'user' | 'merchantAccount'>;
|
||||
unitOfWork?: UnitOfWork;
|
||||
};
|
||||
|
||||
export class CreateFirstGroup {
|
||||
constructor(private readonly deps: CreateFirstGroupDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
data: CreateFirstGroupInput,
|
||||
userId: string,
|
||||
): Promise<Result<CreateFirstGroupResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
const unitOfWork = this.deps.unitOfWork ?? new UnitOfWork(prisma);
|
||||
|
||||
const user = await db.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
return err(problemDetails({ title: 'Not Found', status: 404, code: 'user_not_found', detail: 'Usuario no encontrado.' }));
|
||||
}
|
||||
if (user.onboardingCompleted) {
|
||||
return err(onboardingAlreadyCompletedProblem());
|
||||
}
|
||||
|
||||
const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } });
|
||||
if (!merchantAccount) {
|
||||
return err(paymentNotSetupProblem());
|
||||
}
|
||||
|
||||
const transaction = unitOfWork.executeResult(
|
||||
async (tx: Prisma.TransactionClient) => {
|
||||
const created = await tx.group.create({
|
||||
data: {
|
||||
name: data.name,
|
||||
createdById: userId,
|
||||
days: data.days,
|
||||
time: data.time,
|
||||
capacity: data.capacity,
|
||||
price: data.price,
|
||||
billingType: data.billingType,
|
||||
dueDay: data.dueDay,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.groupMember.create({
|
||||
data: {
|
||||
groupId: created.id,
|
||||
userId,
|
||||
role: Role.OWNER,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.user.update({
|
||||
where: { id: userId },
|
||||
data: { onboardingCompleted: true },
|
||||
});
|
||||
|
||||
return ok(created);
|
||||
},
|
||||
);
|
||||
|
||||
const result = await transaction;
|
||||
if (!result.ok) {
|
||||
return result;
|
||||
}
|
||||
|
||||
return ok({
|
||||
group: toOnboardingGroupDto(result.value),
|
||||
onboardingCompleted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { ConnectPayment } from '@gruperly/shared';
|
||||
import { ConnectPaymentSchema } from '@gruperly/shared';
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { validate } from '@/http/validate';
|
||||
import { PaymentSetup } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.post('/', validate.json(ConnectPaymentSchema), async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
|
||||
const data = c.req.valid('json') as ConnectPayment;
|
||||
const useCase = new PaymentSetup();
|
||||
const result = await useCase.execute(data, user.id);
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { ConnectPayment, ConnectPaymentResult, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { ok } from '@gruperly/shared';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
type PaymentSetupDeps = { db?: PrismaClient };
|
||||
|
||||
export class PaymentSetup {
|
||||
constructor(private readonly deps: PaymentSetupDeps = {}) {}
|
||||
|
||||
async execute(
|
||||
data: ConnectPayment,
|
||||
userId: string,
|
||||
): Promise<Result<ConnectPaymentResult, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
await db.merchantAccount.upsert({
|
||||
where: { userId },
|
||||
create: {
|
||||
userId,
|
||||
provider: data.provider,
|
||||
accessToken: data.accessToken,
|
||||
sandbox: data.sandbox,
|
||||
},
|
||||
update: {
|
||||
provider: data.provider,
|
||||
accessToken: data.accessToken,
|
||||
sandbox: data.sandbox,
|
||||
},
|
||||
});
|
||||
|
||||
return ok({
|
||||
provider: data.provider,
|
||||
sandbox: data.sandbox,
|
||||
connected: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
16
apps/backend/src/modules/onboarding/features/status/route.ts
Normal file
16
apps/backend/src/modules/onboarding/features/status/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Hono } from 'hono';
|
||||
import { problemJson, resultJson, unauthorizedProblem } from '@/http/problem-details';
|
||||
import { GetOnboardingStatus } from './use-case';
|
||||
|
||||
const route = new Hono();
|
||||
|
||||
route.get('/', async (c) => {
|
||||
const user = c.get('user');
|
||||
if (!user) return problemJson(c, unauthorizedProblem(c.req.path));
|
||||
|
||||
const useCase = new GetOnboardingStatus();
|
||||
const result = await useCase.execute(user.id);
|
||||
return resultJson(c, result);
|
||||
});
|
||||
|
||||
export default route;
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { PrismaClient } from '@generated/prisma/client';
|
||||
import type { OnboardingStatusDto, ProblemDetails, Result } from '@gruperly/shared';
|
||||
import { err, ok } from '@gruperly/shared';
|
||||
import { problemDetails } from '@/http/problem-details';
|
||||
import prisma from '@/lib/prisma';
|
||||
|
||||
type GetOnboardingStatusDeps = { db?: PrismaClient };
|
||||
|
||||
export class GetOnboardingStatus {
|
||||
constructor(private readonly deps: GetOnboardingStatusDeps = {}) {}
|
||||
|
||||
async execute(userId: string): Promise<Result<OnboardingStatusDto, ProblemDetails>> {
|
||||
const db = this.deps.db ?? prisma;
|
||||
|
||||
const user = await db.user.findUnique({ where: { id: userId } });
|
||||
if (!user) {
|
||||
return err(
|
||||
problemDetails({
|
||||
title: 'Not Found',
|
||||
status: 404,
|
||||
code: 'user_not_found',
|
||||
detail: 'Usuario no encontrado.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
if (user.onboardingCompleted) {
|
||||
return ok({ step: 'COMPLETED', paymentConnected: true, completed: true });
|
||||
}
|
||||
|
||||
const merchantAccount = await db.merchantAccount.findUnique({ where: { userId } });
|
||||
if (merchantAccount) {
|
||||
return ok({ step: 'PAYMENT_CONNECTED', paymentConnected: true, completed: false });
|
||||
}
|
||||
|
||||
return ok({ step: 'NOT_STARTED', paymentConnected: false, completed: false });
|
||||
}
|
||||
}
|
||||
1
apps/backend/src/modules/onboarding/index.ts
Normal file
1
apps/backend/src/modules/onboarding/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default as onboardingRoutes } from './routes';
|
||||
35
apps/backend/src/modules/onboarding/lib/helpers.ts
Normal file
35
apps/backend/src/modules/onboarding/lib/helpers.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import type { BillingType, OnboardingGroupDto, WeekDay } from '@gruperly/shared';
|
||||
|
||||
type PriceLike = { toString(): string };
|
||||
|
||||
export type OnboardingGroupRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
createdById: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
days: WeekDay[] | null;
|
||||
time: string | null;
|
||||
capacity: number | null;
|
||||
price: PriceLike | number | null;
|
||||
billingType: BillingType | null;
|
||||
dueDay: number | null;
|
||||
};
|
||||
|
||||
export function toOnboardingGroupDto(record: OnboardingGroupRecord): OnboardingGroupDto {
|
||||
return {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
description: record.description,
|
||||
createdById: record.createdById,
|
||||
createdAt: record.createdAt.toISOString(),
|
||||
updatedAt: record.updatedAt.toISOString(),
|
||||
days: record.days,
|
||||
time: record.time,
|
||||
capacity: record.capacity,
|
||||
price: record.price == null ? null : Number(record.price.toString()),
|
||||
billingType: record.billingType,
|
||||
dueDay: record.dueDay,
|
||||
};
|
||||
}
|
||||
1
apps/backend/src/modules/onboarding/lib/index.ts
Normal file
1
apps/backend/src/modules/onboarding/lib/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { type OnboardingGroupRecord, toOnboardingGroupDto } from './helpers';
|
||||
12
apps/backend/src/modules/onboarding/routes.ts
Normal file
12
apps/backend/src/modules/onboarding/routes.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Hono } from 'hono';
|
||||
import firstGroupRoute from './features/first-group/route';
|
||||
import paymentSetupRoute from './features/payment-setup/route';
|
||||
import statusRoute from './features/status/route';
|
||||
|
||||
const routes = new Hono();
|
||||
|
||||
routes.route('/status', statusRoute);
|
||||
routes.route('/payment-setup', paymentSetupRoute);
|
||||
routes.route('/first-group', firstGroupRoute);
|
||||
|
||||
export default routes;
|
||||
@@ -36,6 +36,12 @@ const group = {
|
||||
createdById: userId,
|
||||
createdAt: new Date('2026-08-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
|
||||
days: null,
|
||||
time: null,
|
||||
capacity: null,
|
||||
price: null,
|
||||
billingType: null,
|
||||
dueDay: null,
|
||||
};
|
||||
|
||||
function makeApp(userValue: unknown) {
|
||||
|
||||
281
apps/backend/test/onboarding.test.ts
Normal file
281
apps/backend/test/onboarding.test.ts
Normal file
@@ -0,0 +1,281 @@
|
||||
import { beforeEach, describe, expect, it, mock, vi } from 'bun:test';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
const db = {
|
||||
user: {
|
||||
findUnique: mock(),
|
||||
update: mock(),
|
||||
},
|
||||
merchantAccount: {
|
||||
findUnique: mock(),
|
||||
upsert: mock(),
|
||||
},
|
||||
group: {
|
||||
create: mock(),
|
||||
},
|
||||
groupMember: {
|
||||
create: mock(),
|
||||
},
|
||||
};
|
||||
|
||||
mock.module('@/lib/prisma', () => ({
|
||||
default: db,
|
||||
getPrismaClient: mock(),
|
||||
UnitOfWork: class {
|
||||
executeResult = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
|
||||
execute = mock(async (cb: (tx: unknown) => Promise<unknown>) => cb(db));
|
||||
},
|
||||
}));
|
||||
|
||||
import prisma from '@/lib/prisma';
|
||||
import { onboardingRoutes } from '@/modules/onboarding';
|
||||
|
||||
const userId = 'user-1';
|
||||
|
||||
function makeApp(userValue: unknown) {
|
||||
const app = new Hono();
|
||||
app.use('*', async (c, next) => {
|
||||
c.set('user', userValue as never);
|
||||
await next();
|
||||
});
|
||||
app.route('/onboarding', onboardingRoutes);
|
||||
return app;
|
||||
}
|
||||
|
||||
const group = {
|
||||
id: 'group-1',
|
||||
name: 'Clase de Yoga',
|
||||
description: null,
|
||||
createdById: userId,
|
||||
createdAt: new Date('2026-08-01T10:00:00.000Z'),
|
||||
updatedAt: new Date('2026-08-01T10:00:00.000Z'),
|
||||
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
|
||||
time: '09:00',
|
||||
capacity: 20,
|
||||
price: 150.5,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 5,
|
||||
};
|
||||
|
||||
const firstGroupPayload = {
|
||||
name: group.name,
|
||||
days: group.days,
|
||||
time: group.time,
|
||||
capacity: group.capacity,
|
||||
price: group.price,
|
||||
billingType: group.billingType,
|
||||
dueDay: group.dueDay,
|
||||
};
|
||||
|
||||
describe('onboarding routes', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('GET /onboarding/status', () => {
|
||||
it('returns NOT_STARTED for a user that has not started', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
|
||||
prisma.merchantAccount.findUnique.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/onboarding/status');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
step: 'NOT_STARTED',
|
||||
paymentConnected: false,
|
||||
completed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns PAYMENT_CONNECTED when the merchant account exists', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
|
||||
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId });
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/onboarding/status');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
step: 'PAYMENT_CONNECTED',
|
||||
paymentConnected: true,
|
||||
completed: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('returns COMPLETED when onboarding is already done', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: true });
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/onboarding/status');
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
step: 'COMPLETED',
|
||||
paymentConnected: true,
|
||||
completed: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects without a session', async () => {
|
||||
const res = await makeApp(null).request('/onboarding/status');
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
expect(await res.json()).toMatchObject({ code: 'unauthorized' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /onboarding/payment-setup', () => {
|
||||
it('connects a merchant account in sandbox mode', async () => {
|
||||
prisma.merchantAccount.upsert.mockResolvedValue({ id: 'ma-1', userId });
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/onboarding/payment-setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider: 'MERCADO_PAGO', sandbox: true }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.json()).toEqual({
|
||||
provider: 'MERCADO_PAGO',
|
||||
sandbox: true,
|
||||
connected: true,
|
||||
});
|
||||
expect(prisma.merchantAccount.upsert).toHaveBeenCalledWith({
|
||||
where: { userId },
|
||||
create: { userId, provider: 'MERCADO_PAGO', accessToken: undefined, sandbox: true },
|
||||
update: { provider: 'MERCADO_PAGO', accessToken: undefined, sandbox: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects an unknown provider', async () => {
|
||||
const res = await makeApp({ id: userId }).request('/onboarding/payment-setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider: 'PAYPAL' }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it('rejects without a session', async () => {
|
||||
const res = await makeApp(null).request('/onboarding/payment-setup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ provider: 'STRIPE', sandbox: true }),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /onboarding/first-group', () => {
|
||||
it('creates the first group and completes onboarding', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
|
||||
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId });
|
||||
prisma.group.create.mockResolvedValue(group);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(firstGroupPayload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(201);
|
||||
expect(await res.json()).toEqual({
|
||||
group: {
|
||||
id: 'group-1',
|
||||
name: 'Clase de Yoga',
|
||||
description: null,
|
||||
createdById: userId,
|
||||
createdAt: group.createdAt.toISOString(),
|
||||
updatedAt: group.updatedAt.toISOString(),
|
||||
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
|
||||
time: '09:00',
|
||||
capacity: 20,
|
||||
price: 150.5,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 5,
|
||||
},
|
||||
onboardingCompleted: true,
|
||||
});
|
||||
expect(prisma.group.create).toHaveBeenCalledWith({
|
||||
data: {
|
||||
name: 'Clase de Yoga',
|
||||
createdById: userId,
|
||||
days: ['MONDAY', 'WEDNESDAY', 'FRIDAY'],
|
||||
time: '09:00',
|
||||
capacity: 20,
|
||||
price: 150.5,
|
||||
billingType: 'MONTHLY',
|
||||
dueDay: 5,
|
||||
},
|
||||
});
|
||||
expect(prisma.groupMember.create).toHaveBeenCalledWith({
|
||||
data: { groupId: 'group-1', userId, role: 'OWNER' },
|
||||
});
|
||||
expect(prisma.user.update).toHaveBeenCalledWith({
|
||||
where: { id: userId },
|
||||
data: { onboardingCompleted: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects when the payment has not been set up', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
|
||||
prisma.merchantAccount.findUnique.mockResolvedValue(null);
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(firstGroupPayload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toMatchObject({ code: 'payment_not_setup' });
|
||||
expect(prisma.group.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects when onboarding is already completed', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: true });
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(firstGroupPayload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(await res.json()).toMatchObject({ code: 'onboarding_already_completed' });
|
||||
expect(prisma.group.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects invalid payloads', async () => {
|
||||
prisma.user.findUnique.mockResolvedValue({ id: userId, onboardingCompleted: false });
|
||||
prisma.merchantAccount.findUnique.mockResolvedValue({ id: 'ma-1', userId });
|
||||
|
||||
const res = await makeApp({ id: userId }).request('/onboarding/first-group', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
name: 'Ab',
|
||||
days: [],
|
||||
time: '25:99',
|
||||
capacity: 0,
|
||||
price: -1,
|
||||
billingType: 'WEEKLY',
|
||||
dueDay: 31,
|
||||
}),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(prisma.group.create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects without a session', async () => {
|
||||
const res = await makeApp(null).request('/onboarding/first-group', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(firstGroupPayload),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
expect(res.status).toBe(401);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user