Compare commits

..

3 Commits

10 changed files with 91 additions and 21 deletions

View File

@@ -290,7 +290,7 @@ export class StoreLayoutComponent implements OnInit {
this.toastService.danger(message); this.toastService.danger(message);
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
} }

View File

@@ -83,6 +83,20 @@ describe('CartService', () => {
req.flush({ data: mockCart }); req.flush({ data: mockCart });
}); });
it('refreshes catalog availability only after the expired cart has been reloaded', () => {
const availabilityChanged = vi.fn();
catalogAvailabilityService.availabilityChanged$.subscribe(availabilityChanged);
service.loadCart(true).subscribe();
expect(availabilityChanged).not.toHaveBeenCalled();
const req = httpMock.expectOne('http://api.test/tenants/acme/cart');
req.flush({ data: mockCart });
expect(service.cart()).toEqual(mockCart);
expect(availabilityChanged).toHaveBeenCalledOnce();
});
it('propagates a custom loading mode to the request context', () => { it('propagates a custom loading mode to the request context', () => {
service.withCustomLoading().loadCart().subscribe(); service.withCustomLoading().loadCart().subscribe();

View File

@@ -34,14 +34,19 @@ export class CartService extends BaseApiService {
}); });
} }
loadCart(): Observable<Cart> { loadCart(refreshCatalogAvailability = false): Observable<Cart> {
return this.http return this.http
.get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, { .get<ApiResponse<Cart>>(`${this.tenantApiUrl}/cart`, {
withCredentials: true, withCredentials: true,
}) })
.pipe( .pipe(
map((response) => response.data), map((response) => response.data),
tap((cart) => this.cartState.set(cart)), tap((cart) => {
this.cartState.set(cart);
if (refreshCatalogAvailability) {
this.catalogAvailabilityService.notifyAvailabilityChanged();
}
}),
); );
} }

View File

@@ -1,9 +1,10 @@
import { provideHttpClient } from '@angular/common/http'; import { provideHttpClient } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
import { CheckoutService } from './checkout.service'; import { CheckoutService } from './checkout.service';
describe('CheckoutService', () => { describe('CheckoutService', () => {
@@ -39,6 +40,45 @@ describe('CheckoutService', () => {
await expect(purchasePromise).resolves.toMatchObject({ id: 55 }); await expect(purchasePromise).resolves.toMatchObject({ id: 55 });
}); });
it('refreshes catalog availability when starting checkout fails', async () => {
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
const notifyAvailabilityChanged = vi.spyOn(
catalogAvailabilityService,
'notifyAvailabilityChanged',
);
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
request.flush(
{ message: 'No hay stock disponible.' },
{ status: 422, statusText: 'Unprocessable Entity' },
);
await expect(purchasePromise).rejects.toBeTruthy();
expect(notifyAvailabilityChanged).toHaveBeenCalledOnce();
});
it('waits for the expired cart refresh before refreshing catalog availability', async () => {
const catalogAvailabilityService = TestBed.inject(CatalogAvailabilityService);
const notifyAvailabilityChanged = vi.spyOn(
catalogAvailabilityService,
'notifyAvailabilityChanged',
);
const purchasePromise = service.startCheckout('desfile', { cart_id: 12 });
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/start-checkout`);
request.flush(
{
code: 'stock_reservation.expired',
message: 'La reserva de stock venció.',
},
{ status: 422, statusText: 'Unprocessable Entity' },
);
await expect(purchasePromise).rejects.toBeTruthy();
expect(notifyAvailabilityChanged).not.toHaveBeenCalled();
});
it('preserves checkout timing fields when completing a purchase', async () => { it('preserves checkout timing fields when completing a purchase', async () => {
const purchasePromise = service.completePurchase('desfile', 55); const purchasePromise = service.completePurchase('desfile', 55);
const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/55/complete`); const request = httpMock.expectOne(`${environment.url}tenants/desfile/compras/55/complete`);

View File

@@ -1,10 +1,11 @@
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { firstValueFrom } from 'rxjs'; import { firstValueFrom } from 'rxjs';
import { environment } from '../../../environments/environment'; import { environment } from '../../../environments/environment';
import { ApiPaginatedResponse } from './api-paginated-response.interface'; import { ApiPaginatedResponse } from './api-paginated-response.interface';
import { ApiPaginationQueryParams } from './api-pagination-query-params.interface'; import { ApiPaginationQueryParams } from './api-pagination-query-params.interface';
import { BaseApiService } from './base-api.service'; import { BaseApiService } from './base-api.service';
import { CatalogAvailabilityService } from './catalog/catalog-availability.service';
export interface UpdatePurchaseCustomerPayload { export interface UpdatePurchaseCustomerPayload {
dni: string; dni: string;
@@ -126,24 +127,34 @@ export interface PurchaseDetailResponse extends PurchaseStatusResponse {
providedIn: 'root', providedIn: 'root',
}) })
export class CheckoutService extends BaseApiService { export class CheckoutService extends BaseApiService {
private readonly catalogAvailabilityService = inject(CatalogAvailabilityService);
async startCheckout( async startCheckout(
tenantCode: string, tenantCode: string,
payload: StartCheckoutPayload, payload: StartCheckoutPayload,
): Promise<PurchaseDetailResponse> { ): Promise<PurchaseDetailResponse> {
const response = await firstValueFrom( try {
this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>( const response = await firstValueFrom(
`${environment.url}tenants/${tenantCode}/compras/start-checkout`, this.http.post<{ data?: PurchaseDetailResponse } | PurchaseDetailResponse>(
payload, `${environment.url}tenants/${tenantCode}/compras/start-checkout`,
), payload,
); ),
);
const purchase = this.extractResponseData<PurchaseDetailResponse>(response); const purchase = this.extractResponseData<PurchaseDetailResponse>(response);
if (!purchase?.id) { if (!purchase?.id) {
throw new Error('Error al crear la compra.'); throw new Error('Error al crear la compra.');
}
return purchase;
} catch (error) {
const responseBody = (error as { error?: unknown } | null)?.error;
if (!isExpiredStockReservationResponse(responseBody)) {
this.catalogAvailabilityService.notifyAvailabilityChanged();
}
throw error;
} }
return purchase;
} }
async generatePaymentIntent( async generatePaymentIntent(

View File

@@ -180,7 +180,7 @@ export class CategoryItemsPageComponent {
this.toastService.danger(message); this.toastService.danger(message);
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
} }

View File

@@ -326,7 +326,7 @@ export class CheckoutPageComponent implements OnInit, OnDestroy {
if (this.isStockReservationExpiredError(error)) { if (this.isStockReservationExpiredError(error)) {
this.showRequestError(error, 'La reserva de stock venció.'); this.showRequestError(error, 'La reserva de stock venció.');
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
this.createdPurchaseId.set(null); this.createdPurchaseId.set(null);

View File

@@ -212,7 +212,7 @@ export class SearchPageComponent {
this.toastService.danger(message); this.toastService.danger(message);
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
} }

View File

@@ -224,7 +224,7 @@ export class StoreHomePageComponent implements OnInit, OnDestroy {
if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) { if (error instanceof HttpErrorResponse && isExpiredStockReservationResponse(error.error)) {
this.toastService.danger(error.error.message); this.toastService.danger(error.error.message);
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
return; return;

View File

@@ -330,7 +330,7 @@ export class CartComponent {
return; return;
} }
this.cartService.loadCart().subscribe({ this.cartService.loadCart(true).subscribe({
error: (refreshError) => console.error('Error refreshing expired cart', refreshError), error: (refreshError) => console.error('Error refreshing expired cart', refreshError),
}); });
} }