From 60245890c52554dac0ebde9a11f2e910eda359ca Mon Sep 17 00:00:00 2001 From: masch Date: Sun, 24 May 2026 15:52:13 -0300 Subject: [PATCH 1/7] feat(mobile): connect orders and reservations flow to backend --- .engram/config.json | 2 +- Makefile | 5 +- apps/backend/src/app.ts | 2 + .../src/routes/reservations-mounted.test.ts | 31 +++ .../src/services/reservation.service.test.ts | 13 +- .../src/__tests__/booking-flow-rest.test.tsx | 174 ++++++++++++++ .../src/app/tourist/__tests__/index.test.tsx | 7 +- apps/mobile/src/app/tourist/booking.tsx | 18 +- apps/mobile/src/app/tourist/index.tsx | 63 +++-- .../entrepreneur/ReservationCard.tsx | 35 ++- .../src/constants/__tests__/moments.test.ts | 63 +++++ apps/mobile/src/constants/moments.ts | 33 +++ .../hooks/__tests__/useTimeValidation.test.ts | 50 +++- apps/mobile/src/hooks/useTimeValidation.ts | 30 +++ apps/mobile/src/i18n/locales/en.json | 4 + apps/mobile/src/i18n/locales/es.json | 6 +- .../src/services/__tests__/api-client.test.ts | 134 +++++++++++ .../services/__tests__/order.service.test.ts | 79 +++++++ .../__tests__/product.service.test.ts | 219 ++++++++++++++++++ apps/mobile/src/services/api-client.ts | 57 +++++ apps/mobile/src/services/order.service.ts | 99 ++++++-- apps/mobile/src/services/product.service.ts | 99 ++++---- .../apply-progress.md | 48 ++++ .../archive-report.md | 87 +++++++ .../exploration.md | 0 .../proposal.md | 0 .../spec.md | 0 .../tasks.md | 8 +- .../apply-progress.md | 5 +- .../archive-report.md | 91 ++++++++ .../design.md | 0 .../explore.md | 0 .../proposal.md | 0 .../spec.md | 0 .../tasks.md | 4 +- packages/shared/src/types/reservation.ts | 6 +- 36 files changed, 1372 insertions(+), 100 deletions(-) create mode 100644 apps/backend/src/routes/reservations-mounted.test.ts create mode 100644 apps/mobile/src/__tests__/booking-flow-rest.test.tsx create mode 100644 apps/mobile/src/constants/__tests__/moments.test.ts create mode 100644 apps/mobile/src/services/__tests__/api-client.test.ts create mode 100644 apps/mobile/src/services/__tests__/order.service.test.ts create mode 100644 apps/mobile/src/services/__tests__/product.service.test.ts create mode 100644 apps/mobile/src/services/api-client.ts create mode 100644 openspec/changes/archive/2026-05-24-connect-mobile-orders-api/apply-progress.md create mode 100644 openspec/changes/archive/2026-05-24-connect-mobile-orders-api/archive-report.md rename openspec/changes/{connect-mobile-orders-api => archive/2026-05-24-connect-mobile-orders-api}/exploration.md (100%) rename openspec/changes/{connect-mobile-orders-api => archive/2026-05-24-connect-mobile-orders-api}/proposal.md (100%) rename openspec/changes/{connect-mobile-orders-api => archive/2026-05-24-connect-mobile-orders-api}/spec.md (100%) rename openspec/changes/{connect-mobile-orders-api => archive/2026-05-24-connect-mobile-orders-api}/tasks.md (91%) rename openspec/changes/{orders-endpoints-booking-flow => archive/2026-05-24-orders-endpoints-booking-flow}/apply-progress.md (94%) create mode 100644 openspec/changes/archive/2026-05-24-orders-endpoints-booking-flow/archive-report.md rename openspec/changes/{orders-endpoints-booking-flow => archive/2026-05-24-orders-endpoints-booking-flow}/design.md (100%) rename openspec/changes/{orders-endpoints-booking-flow => archive/2026-05-24-orders-endpoints-booking-flow}/explore.md (100%) rename openspec/changes/{orders-endpoints-booking-flow => archive/2026-05-24-orders-endpoints-booking-flow}/proposal.md (100%) rename openspec/changes/{orders-endpoints-booking-flow => archive/2026-05-24-orders-endpoints-booking-flow}/spec.md (100%) rename openspec/changes/{orders-endpoints-booking-flow => archive/2026-05-24-orders-endpoints-booking-flow}/tasks.md (98%) diff --git a/.engram/config.json b/.engram/config.json index 851046b..9eec1e1 100644 --- a/.engram/config.json +++ b/.engram/config.json @@ -2,7 +2,7 @@ "project_name": "impenetrable-connect", "artifact_store": "hybrid", "strict_tdd": true, - "test_command": "make test", + "test_command": "make validate", "default_language": "en", "delivery_strategy": "ask-on-risk", "execution_mode": "interactive", diff --git a/Makefile b/Makefile index 6a8d635..55ad1e7 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help setup install clean lint gga format check test test-shared test-coverage \ +.PHONY: help setup install clean lint gga format check test validate test-shared test-coverage \ dev dev-api dev-mock dev-web-api \ mobile mobile-mock mobile-mock-web mobile-api mobile-native mobile-clean mobile-web-mock mobile-web-api \ mobile-android mobile-android-native mobile-ios mobile-ios-native mobile-dev \ @@ -85,6 +85,7 @@ help: @echo " make typecheck - Run TypeScript check" @echo " make gga - Run Gentleman Guardian Angel" @echo " make check - Run all checks (mobile + backend + gga)" + @echo " make validate - Run all checks and tests" @echo " make db-reset - Full database reset and seed" @echo "" @echo " 🤖 ANDROID EMULATOR" @@ -413,6 +414,8 @@ gga: check: check-static gga +validate: check test + check-static: format check-static-mobile check-static-backend check-mobile: check-static-mobile gga diff --git a/apps/backend/src/app.ts b/apps/backend/src/app.ts index d77919e..a778c07 100644 --- a/apps/backend/src/app.ts +++ b/apps/backend/src/app.ts @@ -6,6 +6,7 @@ import { venturesRouter } from "./routes/ventures"; import { authRouter } from "./routes/auth"; import { productsRouter } from "./routes/products"; import { servicesRouter } from "./routes/services"; +import { reservationsRouter } from "./routes/reservations"; import { ordersRouter } from "./routes/orders"; import { cors } from "hono/cors"; import { dbMiddleware } from "./middleware/db"; @@ -56,6 +57,7 @@ app.route("/v1/ventures", venturesRouter); app.route("/v1/auth", authRouter); app.route("/v1/products", productsRouter); app.route("/v1/services", servicesRouter); +app.route("/v1/reservations", reservationsRouter); app.route("/v1/orders", ordersRouter); export default app; diff --git a/apps/backend/src/routes/reservations-mounted.test.ts b/apps/backend/src/routes/reservations-mounted.test.ts new file mode 100644 index 0000000..13fad0e --- /dev/null +++ b/apps/backend/src/routes/reservations-mounted.test.ts @@ -0,0 +1,31 @@ +import { describe, expect, it } from "bun:test"; +import app from "../app"; + +const TEST_ENV = { + DATABASE_URL: "postgres://localhost:5432/db", + JWT_SECRET: "test-secret", + HEALTH_TOKEN: "test-health-token", +}; + +describe("App — route mounting", () => { + const routes: Array<[string, string]> = [ + ["GET", "/health"], + ["GET", "/v1/projects"], + ["GET", "/v1/ventures"], + ["GET", "/v1/services"], + ["POST", "/v1/reservations"], + ["PATCH", "/v1/reservations/test"], + ["POST", "/v1/orders"], + ["GET", "/v1/orders"], + ]; + + it.each(routes)("%s %s should be mounted (responds non-404)", async (method, path) => { + const res = await app.request(path, { method }, TEST_ENV); + expect(res.status).not.toBe(404); + }); + + it("health endpoint returns 200", async () => { + const res = await app.request("/health", {}, TEST_ENV); + expect(res.status).toBe(200); + }); +}); diff --git a/apps/backend/src/services/reservation.service.test.ts b/apps/backend/src/services/reservation.service.test.ts index e97dd06..a74b157 100644 --- a/apps/backend/src/services/reservation.service.test.ts +++ b/apps/backend/src/services/reservation.service.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "bun:test"; -import { ReservationService, ReservationValidationError, ReservationAccessError } from "./reservation.service"; +import { + ReservationService, + ReservationValidationError, + ReservationAccessError, +} from "./reservation.service"; import { type Db } from "../db"; describe("ReservationService", () => { @@ -138,7 +142,12 @@ describe("ReservationService", () => { it("should throw ReservationAccessError when TOURIST accesses another's reservation", async () => { await expect( - ReservationService.getById(mockDb, mockReservation.zzz_id, "TOURIST" as never, "other-user"), + ReservationService.getById( + mockDb, + mockReservation.zzz_id, + "TOURIST" as never, + "other-user", + ), ).rejects.toThrow(ReservationAccessError); }); diff --git a/apps/mobile/src/__tests__/booking-flow-rest.test.tsx b/apps/mobile/src/__tests__/booking-flow-rest.test.tsx new file mode 100644 index 0000000..64734da --- /dev/null +++ b/apps/mobile/src/__tests__/booking-flow-rest.test.tsx @@ -0,0 +1,174 @@ +import { render, screen, fireEvent, waitFor } from "./utils/test-utils"; +import BookingScreen from "../app/tourist/booking"; +import { useCartStore } from "../stores/cart.store"; +import { useCatalogStore } from "../stores/product.store"; +import { useReservationStore } from "../stores/reservation.store"; +import { useAuthStore } from "../stores/auth.store"; + +// Set REST mode for this test file +jest.mock("../config/env", () => ({ + __esModule: true, + default: { API_URL: "http://test.api/v1", USE_MOCKS: false }, +})); + +jest.mock("../stores/cart.store"); +jest.mock("../stores/product.store"); +jest.mock("../stores/reservation.store"); +jest.mock("../stores/auth.store"); +jest.mock("expo-router", () => ({ + useRouter: () => ({ push: jest.fn(), replace: jest.fn() }), + router: { push: jest.fn(), replace: jest.fn() }, +})); + +const mockDate = new Date("2026-04-20T12:00:00Z"); +const mockServices = [ + { + zzz_id: 1, + zzz_name_i18n: { es: "Empanadas", en: "Empanadas" }, + zzz_description_i18n: { es: "De carne", en: "Meat" }, + zzz_price: 1500, + zzz_product_category_id: 1, + zzz_image_url: "empanadas6.jpg", + zzz_service_moments: ["LUNCH", "DINNER"], + }, +]; + +function setupMocks( + overrides: { + cart?: Record; + catalog?: Record; + reservation?: Record; + auth?: Record; + } = {}, +) { + const defaultCart = { + selectedDate: mockDate, + selectedMoment: "LUNCH" as const, + selectedTime: "12:00", + guestCount: 2, + cartItems: [{ zzz_catalog_item_id: 1, zzz_quantity: 2, zzz_price: 1500 }], + isValid: () => true, + addItem: jest.fn(), + removeItem: jest.fn(), + clearCart: jest.fn(), + resetContext: jest.fn(), + }; + + const defaultCatalog = { + services: mockServices, + isLoading: false, + fetchServices: jest.fn(), + placeOrder: jest.fn(), + }; + + const defaultReservation = { + activeOrders: [], + fetchOrders: jest.fn(), + cancelOrder: jest.fn(), + addOrder: jest.fn(), + }; + + const defaultAuth = { isAuthenticated: true }; + + const finalCart = { ...defaultCart, ...overrides.cart }; + const finalCatalog = { ...defaultCatalog, ...overrides.catalog }; + const finalReservation = { ...defaultReservation, ...overrides.reservation }; + const finalAuth = { ...defaultAuth, ...overrides.auth }; + + const mockStore = (storeRef: unknown, state: Record) => { + const mock = storeRef as jest.Mock; + mock.mockImplementation((sel?: (s: typeof state) => unknown) => (sel ? sel(state) : state)); + (mock as unknown as { getState: () => Record }).getState = () => state; + }; + + mockStore(useAuthStore, finalAuth); + mockStore(useCatalogStore, finalCatalog); + mockStore(useReservationStore, finalReservation); + mockStore(useCartStore, finalCart); + + return { finalCart, finalCatalog, finalReservation }; +} + +describe("Booking Flow - REST mode (USE_MOCKS=false)", () => { + beforeEach(() => { + jest.clearAllMocks(); + setupMocks(); + }); + + it("should call placeOrder from the store and navigate to orders on success", async () => { + const mockOrder = { + zzz_id: "order-uuid-456", + zzz_reservation_id: "res-uuid-123", + zzz_global_status: "SEARCHING" as const, + }; + const placeOrderMock = jest.fn().mockResolvedValue(mockOrder); + const addOrderMock = jest.fn(); + const clearCartMock = jest.fn(); + + setupMocks({ + catalog: { placeOrder: placeOrderMock }, + reservation: { addOrder: addOrderMock }, + cart: { clearCart: clearCartMock }, + }); + + render(); + + // Wait for the confirm button to appear + await waitFor(() => { + expect(screen.getByTestId("confirm-order-button")).toBeTruthy(); + }); + + // Press confirm → shows AppAlert + fireEvent.press(screen.getByTestId("confirm-order-button")); + + // Find the confirm action inside the alert + const confirmActionBtn = await waitFor(() => screen.getByTestId("alert-action-confirm")); + expect(confirmActionBtn).toBeTruthy(); + fireEvent.press(confirmActionBtn); + + // Verify placeOrder was called with correct args + await waitFor(() => { + expect(placeOrderMock).toHaveBeenCalledWith( + mockDate, + "LUNCH", + [{ zzz_catalog_item_id: 1, zzz_quantity: 2, zzz_notes: undefined }], + 2, + "12:00", + undefined, + ); + }); + + // Verify store updates and navigation + expect(addOrderMock).toHaveBeenCalledWith(mockOrder); + expect(clearCartMock).toHaveBeenCalled(); + }); + + it("should show error alert when placeOrder fails", async () => { + const placeOrderMock = jest.fn().mockRejectedValue(new Error("Order failed")); + + setupMocks({ + catalog: { placeOrder: placeOrderMock }, + }); + + render(); + + await waitFor(() => { + expect(screen.getByTestId("confirm-order-button")).toBeTruthy(); + }); + + fireEvent.press(screen.getByTestId("confirm-order-button")); + + const confirmActionBtn = await waitFor(() => screen.getByTestId("alert-action-confirm")); + expect(confirmActionBtn).toBeTruthy(); + fireEvent.press(confirmActionBtn); + + await waitFor(() => { + expect(placeOrderMock).toHaveBeenCalled(); + }); + + // Error alert should be visible with the error message + await waitFor(() => { + expect(screen.getByText("errors.reservation_failed")).toBeTruthy(); + }); + }); +}); diff --git a/apps/mobile/src/app/tourist/__tests__/index.test.tsx b/apps/mobile/src/app/tourist/__tests__/index.test.tsx index 9255938..35b00df 100644 --- a/apps/mobile/src/app/tourist/__tests__/index.test.tsx +++ b/apps/mobile/src/app/tourist/__tests__/index.test.tsx @@ -106,10 +106,13 @@ describe("OrderSetupScreen", () => { }); it("should set context and navigate to booking when form is valid and submitted", async () => { + jest.useFakeTimers(); + jest.setSystemTime(new Date("2024-01-15T10:00:00-03:00")); + const setContextSpy = jest.spyOn(useCartStore.getState(), "setContext"); useCartStore.setState({ - selectedDate: new Date(), + selectedDate: new Date("2024-01-15T00:00:00-03:00"), selectedMoment: "LUNCH" as ServiceMoment, selectedTime: createHourMinute("13:00"), guestCount: 2, @@ -133,5 +136,7 @@ describe("OrderSetupScreen", () => { expect(setContextSpy).toHaveBeenCalled(); expect(mockPush).toHaveBeenCalledWith("/tourist/booking"); }); + + jest.useRealTimers(); }); }); diff --git a/apps/mobile/src/app/tourist/booking.tsx b/apps/mobile/src/app/tourist/booking.tsx index c4b8bd2..fff0a69 100644 --- a/apps/mobile/src/app/tourist/booking.tsx +++ b/apps/mobile/src/app/tourist/booking.tsx @@ -655,7 +655,7 @@ export default function BookingScreen() { onPress: async () => { if (!selectedDate || !selectedMoment || !selectedTime) return; try { - const newOrder = await placeOrder( + const newOrder: Order | null = await placeOrder( selectedDate, selectedMoment, cartItems.map((i) => ({ @@ -667,6 +667,7 @@ export default function BookingScreen() { selectedTime, generalNotes || undefined, ); + if (newOrder) { addOrderToStore(newOrder); clearCart(); @@ -676,6 +677,21 @@ export default function BookingScreen() { } } catch (err) { logger.error("Final confirmation failed", err); + const message = + err instanceof Error ? err.message : "Unknown error"; + setAlertConfig({ + visible: true, + title: t("errors.reservation_failed"), + message, + type: "error", + actions: [ + { + text: t("common.ok"), + variant: "primary", + onPress: () => {}, + }, + ], + }); } }, }, diff --git a/apps/mobile/src/app/tourist/index.tsx b/apps/mobile/src/app/tourist/index.tsx index 66c7cbd..14e80af 100644 --- a/apps/mobile/src/app/tourist/index.tsx +++ b/apps/mobile/src/app/tourist/index.tsx @@ -7,14 +7,19 @@ import { useCartStore } from "../../stores/cart.store"; import { createHourMinute } from "@repo/shared"; import { useReservationStore } from "../../stores/reservation.store"; import { useProjectStore } from "../../stores/project.store"; -import { SERVICE_MOMENTS, getMomentConfig, getDefaultTimeForMoment } from "../../constants/moments"; +import { + SERVICE_MOMENTS, + getMomentConfig, + getDefaultTimeForMoment, + isMomentExpired, +} from "../../constants/moments"; import { DatePicker } from "../../components/DatePicker"; import { Button } from "../../components/Button"; import { AppDateTimePicker } from "../../components/AppDateTimePicker"; import LoadingView from "../../components/LoadingView"; import { COLORS, ICON_SIZES, FONT_SIZES, ServiceMoment, Order } from "@repo/shared"; import { isSameDay, formatDateToTime, parseTimeToDate } from "../../logic/formatters"; -import { isTimeInRange } from "../../hooks/useTimeValidation"; +import { isTimeInRange, isTimeInPast } from "../../hooks/useTimeValidation"; import Screen, { ScreenContent } from "../../components/Screen"; export default function OrderSetupScreen() { @@ -115,9 +120,13 @@ export default function OrderSetupScreen() { end: config.endTime, }), ); + } else if (isTimeInPast(date, selectedDate)) { + setTimeError(t("order_setup.time_error_past")); } else { setTimeError(null); } + } else { + setTimeError(null); } } }; @@ -137,6 +146,12 @@ export default function OrderSetupScreen() { // Prevent proceeding if time is outside valid range if (timeError) return; + // Prevent proceeding if selected time is in the past (only for today) + if (time && isTimeInPast(date, time)) { + setTimeError(t("order_setup.time_error_past")); + return; + } + // Detect if we need to move existing items to a new context const hasContextChanged = (selectedDate && !isSameDay(date, selectedDate)) || @@ -252,33 +267,49 @@ export default function OrderSetupScreen() { {SERVICE_MOMENTS.map((m) => { const isSelected = moment === m.zzz_id; + const expired = isMomentExpired(m.zzz_id, date); const label = t(m.labelKey); return ( diff --git a/apps/mobile/src/i18n/locales/en.json b/apps/mobile/src/i18n/locales/en.json index 6debb7b..8758869 100644 --- a/apps/mobile/src/i18n/locales/en.json +++ b/apps/mobile/src/i18n/locales/en.json @@ -393,6 +393,7 @@ "time_error_invalid": "Invalid time", "time_error_outside_range": "Time outside allowed range ({{start}} - {{end}})", "time_error_past": "This time has already passed for today", + "time_error_expired": "This time slot has expired for today", "select_time_option": "Select time {{time}}" }, "status": { diff --git a/apps/mobile/src/i18n/locales/es.json b/apps/mobile/src/i18n/locales/es.json index 1b31416..92ff1cb 100644 --- a/apps/mobile/src/i18n/locales/es.json +++ b/apps/mobile/src/i18n/locales/es.json @@ -393,6 +393,7 @@ "time_error_invalid": "Hora inválida", "time_error_outside_range": "Hora fuera del rango permitido ({{start}} - {{end}})", "time_error_past": "Esta hora ya pasó para hoy", + "time_error_expired": "Este horario ya expiró para hoy", "select_time_option": "Seleccionar hora {{time}}" }, "status": { From 6b6f79eade15ba4458c47f093ee039b46145c423 Mon Sep 17 00:00:00 2001 From: masch Date: Sun, 24 May 2026 22:10:53 -0300 Subject: [PATCH 7/7] fix(order-setup): functional setGuestCount, useMemo default time, store updater type - Use functional form for setGuestCount to avoid stale closures (prev -> prev +/- 1) - Add useMemo for getDefaultTimeForMoment to stabilize Date reference across renders - Update cart.store setGuestCount to accept (prev: number) => number updater - Pass functional updater through to Zustand set for consistency --- apps/mobile/src/app/tourist/index.tsx | 14 ++++++++++---- apps/mobile/src/stores/cart.store.ts | 9 +++++++-- 2 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/mobile/src/app/tourist/index.tsx b/apps/mobile/src/app/tourist/index.tsx index 96dabf1..acea811 100644 --- a/apps/mobile/src/app/tourist/index.tsx +++ b/apps/mobile/src/app/tourist/index.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useMemo } from "react"; import { View, Text, ScrollView, Platform } from "react-native"; import { useRouter } from "expo-router"; import { Icon } from "../../components/Icon"; @@ -64,6 +64,12 @@ export default function OrderSetupScreen() { } }, [selectedProject, projects, isLoading, error, fetchProjects, selectProject]); + // Stable default time to avoid hydration mismatch from new Date() in render + const defaultTimeForMoment = useMemo( + () => getDefaultTimeForMoment(moment ?? "BREAKFAST"), + [moment], + ); + if (isLoading || (!selectedProject && !error)) { return ( @@ -235,7 +241,7 @@ export default function OrderSetupScreen() {