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 { App } from './app';
import { AuthService } from './core/services/auth/auth.service'; import { AuthService } from './core/services/auth/auth.service';
import { CartService } from './core/services/cart/cart.service'; import { CartService } from './core/services/cart/cart.service';
import { CheckoutService } from './core/services/checkout.service';
import { Tenant } from './core/services/tenant.interface'; import { Tenant } from './core/services/tenant.interface';
import { TenantService } from './core/services/tenant.service'; import { TenantService } from './core/services/tenant.service';
import { routes } from './app.routes'; import { routes } from './app.routes';
@@ -97,6 +98,21 @@ async function renderAppAt(
{ {
provide: AuthService, provide: AuthService,
useValue: 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(); }).compileComponents();
@@ -246,15 +262,33 @@ describe('app routes', () => {
expect(compiled.textContent).toContain('No encontramos una tienda para este dominio'); expect(compiled.textContent).toContain('No encontramos una tienda para este dominio');
}); });
it('redirects unauthenticated users from /checkout to /login', async () => { it('redirects unauthenticated users from /checkout/:id to /login', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(false)); 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 () => { it('allows authenticated users to access /checkout/:id', async () => {
const { router } = await renderAppAt('/checkout', createTenantServiceStub(), createAuthServiceStub(true)); 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', () => { 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(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe('/'); 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', () => { it('disables the cart icon and prevents opening the popup during checkout with a tenant base path', () => {
tenantState.set({ ...tenant, base_path: 'fiesta' }); tenantState.set({ ...tenant, base_path: 'fiesta' });
const router = TestBed.inject(Router); 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); const fixture = TestBed.createComponent(StoreLayoutComponent);
fixture.detectChanges(); fixture.detectChanges();
@@ -588,7 +588,7 @@ describe('StoreLayoutComponent', () => {
const authService = TestBed.inject(AuthService); const authService = TestBed.inject(AuthService);
const cartService = TestBed.inject(CartService); const cartService = TestBed.inject(CartService);
const router = TestBed.inject(Router); 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 navigateSpy = vi.spyOn(router, 'navigate').mockResolvedValue(true);
const fixture = TestBed.createComponent(StoreLayoutComponent); const fixture = TestBed.createComponent(StoreLayoutComponent);
@@ -675,9 +675,7 @@ describe('StoreLayoutComponent', () => {
expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', { expect(checkoutServiceStub.startCheckout).toHaveBeenCalledWith('test', {
cart_id: 1, cart_id: 1,
}); });
expect(router.navigate).toHaveBeenCalledWith(['/checkout'], { expect(router.navigate).toHaveBeenCalledWith(['/checkout', 55]);
queryParams: { purchase: 55 },
});
expect((fixture.componentInstance as any).isCartOpen()).toBe(false); expect((fixture.componentInstance as any).isCartOpen()).toBe(false);
}); });

View File

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

View File

@@ -24,12 +24,12 @@ describe('auth guards', () => {
}); });
const result = TestBed.runInInjectionContext(() => 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(result instanceof UrlTree).toBe(true);
expect(TestBed.inject(Router).serializeUrl(result as UrlTree)).toBe( 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(() => 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); 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) { } catch (error) {
console.error('Failed to create direct purchase:', error); console.error('Failed to create direct purchase:', error);
const message = const message =

View File

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

View File

@@ -161,7 +161,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { 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) { if (!Number.isInteger(purchaseId) || purchaseId <= 0) {
void this.router.navigate(['/']); void this.router.navigate(['/']);
return; return;

View File

@@ -507,9 +507,7 @@ describe('ProductDetailPageComponent', () => {
}, },
], ],
}); });
expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout'], { expect(routerStub.navigate).toHaveBeenCalledWith(['/checkout', 44]);
queryParams: { purchase: 44 },
});
}); });
it('shows the backend purchase-limit message for a direct checkout', async () => { 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'], { await this.router.navigate(['/checkout', purchase.id]);
queryParams: { purchase: purchase.id },
});
} catch (error) { } catch (error) {
console.error('Failed to create direct purchase:', error); console.error('Failed to create direct purchase:', error);
const message = 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) { } catch (error) {
console.error('Failed to create direct purchase:', error); console.error('Failed to create direct purchase:', error);
const message = const message =

View File

@@ -218,7 +218,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
tenant.codigo, tenant.codigo,
reservedCartId !== null ? { cart_id: reservedCartId } : { direct_items: directItems }, 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) { } catch (error) {
console.error('Failed to create direct purchase:', error); console.error('Failed to create direct purchase:', error);

View File

@@ -91,15 +91,6 @@ export const routes: Routes = [
(m) => m.ProductDetailPageComponent, (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', path: 'checkout/status',
component: SimpleLayoutComponent, 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', path: 'ayuda',
canActivate: [hasMenuGuard('help')], canActivate: [hasMenuGuard('help')],