refactor(checkout): use purchase id route segment

This commit is contained in:
2026-08-25 17:06:07 -03:00
parent d06b146104
commit 2b342ec235
13 changed files with 74 additions and 47 deletions

View File

@@ -7,6 +7,7 @@ import { of } from 'rxjs';
import { App } from './app';
import { AuthService } from './core/services/auth/auth.service';
import { CartService } from './core/services/cart/cart.service';
import { CheckoutService } from './core/services/checkout.service';
import { Tenant } from './core/services/tenant.interface';
import { TenantService } from './core/services/tenant.service';
import { routes } from './app.routes';
@@ -97,6 +98,21 @@ async function renderAppAt(
{
provide: AuthService,
useValue: authService
},
{
provide: CheckoutService,
useValue: {
withCustomLoading() {
return this;
},
getPurchase: vi.fn().mockResolvedValue({
id: 25,
status: 'created',
items: [],
subtotal: '0.00',
total: '0.00'
})
}
}
]
}).compileComponents();
@@ -246,15 +262,33 @@ describe('app routes', () => {
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
});
it('redirects unauthenticated users from /checkout to /login', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false));
it('redirects unauthenticated users from /checkout/:id to /login', async () => {
const { router } = await renderAppAt('/checkout/25', createTenantServiceStub(), createAuthServiceStub(false));
expect(router.url).toBe('/login?returnUrl=%2Fcheckout');
expect(router.url).toBe('/login?returnUrl=%2Fcheckout%2F25');
});
it('allows authenticated users to access /checkout', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(true));
it('allows authenticated users to access /checkout/:id', async () => {
const checkoutTenant = {
...tenant,
menues: [
{
id: 1,
code: 'checkout',
label: 'Checkout',
parent_menu_code: null,
content_type: 'dynamic' as const,
route: '/checkout',
submenues: []
}
]
};
const { router } = await renderAppAt(
'/checkout/25',
createTenantServiceStub('ready', checkoutTenant),
createAuthServiceStub(true)
);
expect(router.url).toBe('/checkout');
expect(router.url).toBe('/checkout/25');
});
});

View File

@@ -62,7 +62,7 @@ describe('hasMenuGuard', () => {
});
it('redirects a missing menu route to the store root', () => {
const result = runGuard('checkout', '/checkout', tenant);
const result = runGuard('checkout', '/checkout/25', tenant);
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/');

View File

@@ -247,7 +247,7 @@ describe('StoreLayoutComponent', () => {
it('disables the cart icon and prevents opening the popup during checkout with a tenant base path', () => {
tenantState.set({ ...tenant, base_path: 'fiesta' });
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/fiesta/checkout?purchase=25');
vi.spyOn(router, 'url', 'get').mockReturnValue('/fiesta/checkout/25');
const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges();
@@ -588,7 +588,7 @@ describe('StoreLayoutComponent', () => {
const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router);
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout?purchase=25');
vi.spyOn(router, 'url', 'get').mockReturnValue('/checkout/25');
const navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(StoreLayoutComponent);
@@ -675,9 +675,7 @@ describe('StoreLayoutComponent', () => {
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', {
cart_id: 1,
});
expect(router.navigate).toHaveBeenCalledWith(['/checkout'], {
queryParams: { purchase: 55 },
});
expect(router.navigate).toHaveBeenCalledWith(['/checkout', 55]);
expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
});

View File

@@ -277,9 +277,7 @@ export class StoreLayoutComponent implements OnInit {
});
this.isCartOpen.set(false);
await this.router.navigate(['/checkout'], {
queryParams: { purchase: purchase.id },
});
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
console.error('Failed to create cart purchase:', error);
const message =

View File

@@ -24,12 +24,12 @@ describe('auth guards', () => {
});
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout?mode=direct' } as never),
authGuard(null as never, { url: '/checkout/25' } as never),
);
expect(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe(
'/login?returnUrl=%2Fcheckout%3Fmode%3Ddirect',
'/login?returnUrl=%2Fcheckout%2F25',
);
});
@@ -39,7 +39,7 @@ describe('auth guards', () => {
});
const result = TestBed.runInInjectionContext(() =>
authGuard(null as never, { url: '/checkout' } as never),
authGuard(null as never, { url: '/checkout/25' } as never),
);
expect(result).toBe(true);

View File

@@ -170,7 +170,7 @@ export class CategoryItemsPageComponent {
],
},
);
await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } });
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
console.error('Failed to create direct purchase:', error);
const message =

View File

@@ -33,7 +33,7 @@ describe('CheckoutPageComponent payment validation', () => {
stop: ReturnType<typeof vi.fn>;
};
let cartServiceStub: { loadCart: ReturnType<typeof vi.fn> };
let routeQueryParamMap: ReturnType<typeof convertToParamMap>;
let routeParamMap: ReturnType<typeof convertToParamMap>;
let authUserState: ReturnType<typeof signal>;
let tenantState: ReturnType<
typeof signal<{
@@ -76,7 +76,7 @@ describe('CheckoutPageComponent payment validation', () => {
cartServiceStub = {
loadCart: vi.fn().mockReturnValue(of({ id: 30, items: [], subtotal: '0.00' })),
};
routeQueryParamMap = convertToParamMap({});
routeParamMap = convertToParamMap({});
authUserState = signal(null);
tenantState = signal({
codigo: 'tenant-test',
@@ -103,8 +103,8 @@ describe('CheckoutPageComponent payment validation', () => {
provide: ActivatedRoute,
useValue: {
snapshot: {
get queryParamMap() {
return routeQueryParamMap;
get paramMap() {
return routeParamMap;
},
},
},
@@ -317,7 +317,7 @@ describe('CheckoutPageComponent payment validation', () => {
subtotal: '2501.00',
total: '2501.00',
};
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue(purchase);
authUserState.set({
id: 7,
@@ -354,7 +354,7 @@ describe('CheckoutPageComponent payment validation', () => {
it('keeps the checkout hidden while the purchase is loading', async () => {
let resolvePurchase!: (purchase: any) => void;
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockReturnValue(
new Promise((resolve) => {
resolvePurchase = resolve;
@@ -375,12 +375,13 @@ describe('CheckoutPageComponent payment validation', () => {
});
await Promise.resolve();
expect(checkoutServiceStub.getPurchase).toHaveBeenCalledWith('tenant-test', 25);
expect(component.isLoadingPurchase()).toBe(false);
expect(component.checkoutStepIndex()).toBe(0);
});
it('opens a pending purchase on the payment step and restores its payment method', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
@@ -400,7 +401,7 @@ describe('CheckoutPageComponent payment validation', () => {
});
it('generates a new QR when reopening a pending QR purchase', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',
@@ -424,7 +425,7 @@ describe('CheckoutPageComponent payment validation', () => {
it.each(['paid', 'cancelled', 'rejected', 'expired'])(
'redirects a %s purchase to its status page',
async (status) => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status,
@@ -442,7 +443,7 @@ describe('CheckoutPageComponent payment validation', () => {
);
it('redirects a submitted pending payment purchase to its status page', async () => {
routeQueryParamMap = convertToParamMap({ purchase: 25 });
routeParamMap = convertToParamMap({ id: 25 });
checkoutServiceStub.getPurchase.mockResolvedValue({
id: 25,
status: 'pending_payment',

View File

@@ -161,7 +161,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
const purchaseId = Number(this.route.snapshot.queryParamMap.get('purchase'));
const purchaseId = Number(this.route.snapshot.paramMap.get('id'));
if (!Number.isInteger(purchaseId) || purchaseId <= 0) {
void this.router.navigate(['/']);
return;

View File

@@ -507,9 +507,7 @@ describe('ProductDetailPageComponent', () => {
},
],
});
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout'], {
queryParams: { purchase: 44 },
});
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout', 44]);
});
it('shows the backend purchase-limit message for a direct checkout', async () => {

View File

@@ -358,9 +358,7 @@ export class ProductDetailPageComponent implements OnInit, OnDestroy {
],
});
await this.router.navigate(['/checkout'], {
queryParams: { purchase: purchase.id },
});
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
console.error('Failed to create direct purchase:', error);
const message =

View File

@@ -202,7 +202,7 @@ export class SearchPageComponent {
],
},
);
await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } });
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
console.error('Failed to create direct purchase:', error);
const message =

View File

@@ -218,7 +218,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
tenant.codigo,
reservedCartId !== null ? { cart_id: reservedCartId } : { direct_items: directItems },
);
await this.router.navigate(['/checkout'], { queryParams: { purchase: purchase.id } });
await this.router.navigate(['/checkout', purchase.id]);
} catch (error) {
console.error('Failed to create direct purchase:', error);

View File

@@ -91,15 +91,6 @@ export const routes: Routes = [
(m) => m.ProductDetailPageComponent,
),
},
{
path: 'checkout',
canActivate: [authGuard, hasMenuGuard('checkout')],
canDeactivate: [checkoutPendingPurchaseGuard],
loadComponent: () =>
import('./pages/checkout-page/checkout-page.component').then(
(m) => m.CheckoutPageComponent,
),
},
{
path: 'checkout/status',
component: SimpleLayoutComponent,
@@ -113,6 +104,15 @@ export const routes: Routes = [
},
],
},
{
path: 'checkout/:id',
canActivate: [authGuard, hasMenuGuard('checkout')],
canDeactivate: [checkoutPendingPurchaseGuard],
loadComponent: () =>
import('./pages/checkout-page/checkout-page.component').then(
(m) => m.CheckoutPageComponent,
),
},
{
path: 'ayuda',
canActivate: [hasMenuGuard('help')],