diff --git a/apps/backend/src/db/factory.ts b/apps/backend/src/db/factory.ts index 11ae2d9..50d412b 100644 --- a/apps/backend/src/db/factory.ts +++ b/apps/backend/src/db/factory.ts @@ -22,6 +22,27 @@ const schema = { orderItems, }; +/** + * DBA Expert Rule: postgres-js connection options. + * + * IMPORTANT: postgres-js by default has NO connect_timeout, NO idle_timeout, + * and NO max_lifetime. If the TCP connection drops or the DB becomes + * unreachable, queries hang forever — the route handler never returns, + * no timeout error is thrown, and the server appears to freeze. + * + * These options prevent that: + * - connect_timeout: fail fast if the DB host is unreachable + * - idle_timeout: recycle unused connections instead of holding them open + * - max_lifetime: force periodic reconnection to avoid stale TCP sockets + * - max: cap concurrent connections to the pool (postgres-js default is 10) + */ +const POSTGRES_OPTIONS = { + connect_timeout: 10, + idle_timeout: 60, + max_lifetime: 1800, + max: 10, +} as const; + /** * ARCHITECTURE WARNING: * DBA Expert Rule: Factory to create the optimal DB client based on the environment. @@ -34,7 +55,7 @@ export function createDb(databaseUrl: string) { return drizzleNeon(neon(databaseUrl), { schema }); } - return drizzlePostgres(postgres(databaseUrl), { schema }); + return drizzlePostgres(postgres(databaseUrl, POSTGRES_OPTIONS), { schema }); } export type Db = ReturnType; diff --git a/apps/backend/src/db/schema/orders.ts b/apps/backend/src/db/schema/orders.ts index 4c5efd9..8f02302 100644 --- a/apps/backend/src/db/schema/orders.ts +++ b/apps/backend/src/db/schema/orders.ts @@ -25,7 +25,7 @@ export const orders = impenetrableSchema.table("orders", { zzz_reservation_id: uuid("zzz_reservation_id") .references(() => reservations.zzz_id) .notNull(), - zzz_catalog_type_id: integer("zzz_catalog_type_id").notNull(), + zzz_product_category_id: integer("zzz_product_category_id").notNull(), zzz_confirmed_venture_id: integer("zzz_confirmed_venture_id").references(() => ventures.id), zzz_notes: text("zzz_notes"), zzz_global_status: orderStatusEnum("zzz_global_status").notNull().default("SEARCHING"), diff --git a/apps/backend/src/db/schema/ventures.ts b/apps/backend/src/db/schema/ventures.ts index 5120b85..461c068 100644 --- a/apps/backend/src/db/schema/ventures.ts +++ b/apps/backend/src/db/schema/ventures.ts @@ -2,10 +2,13 @@ import { serial, varchar, uuid, boolean, integer } from "drizzle-orm/pg-core"; import { auditColumns, impenetrableSchema } from "./base"; import { users } from "./users"; import { projects } from "./projects"; +import { productCategories } from "./product-categories"; + +const VENTURE_NAME_MAX_LENGTH = 255; export const ventures = impenetrableSchema.table("ventures", { id: serial("id").primaryKey(), - name: varchar("name", { length: 255 }).notNull(), + name: varchar("name", { length: VENTURE_NAME_MAX_LENGTH }).notNull(), ownerId: uuid("owner_id") .notNull() .references(() => users.id), @@ -16,5 +19,8 @@ export const ventures = impenetrableSchema.table("ventures", { zzz_cascade_order: integer("zzz_cascade_order").notNull().default(0), zzz_is_paused: boolean("zzz_is_paused").notNull().default(false), zzz_is_active: boolean("zzz_is_active").notNull().default(true), + zzz_product_category_id: integer("zzz_product_category_id") + .references(() => productCategories.zzz_id) + .notNull(), ...auditColumns, }); diff --git a/apps/backend/src/db/seed.ts b/apps/backend/src/db/seed.ts index 46961c7..b702aa0 100644 --- a/apps/backend/src/db/seed.ts +++ b/apps/backend/src/db/seed.ts @@ -102,6 +102,7 @@ async function seedVentures(db: Db) { zzz_cascade_order: v.zzz_cascade_order, zzz_is_paused: v.zzz_is_paused, zzz_is_active: v.zzz_is_active, + zzz_product_category_id: v.zzz_product_category_id, })); await db.insert(ventures).values(venturesToInsert).onConflictDoNothing(); @@ -159,8 +160,8 @@ async function seed() { await seedProjects(db); await seedUsers(db); - await seedVentures(db); await seedProductCategories(db); + await seedVentures(db); await seedProducts(db); logger.info("✅ Seeding completed!"); diff --git a/apps/backend/src/routes/auth.test.ts b/apps/backend/src/routes/auth.test.ts index 658e062..a230608 100644 --- a/apps/backend/src/routes/auth.test.ts +++ b/apps/backend/src/routes/auth.test.ts @@ -9,10 +9,11 @@ import { UserRole, } from "@repo/shared"; import { authMiddleware, roleGuard } from "../middleware/auth"; -import { dbMiddleware } from "../middleware/db"; +import { dbMiddleware, resetDbCache } from "../middleware/db"; import * as dbFactory from "../db/index"; import { AuthService } from "../services/auth.service"; import { type AppEnv } from "../config/env"; +import { users } from "../db/schema"; const testApp = new Hono(); @@ -291,6 +292,78 @@ describe("Auth API Integration", () => { expect(res.status).toBe(500); }); + + it("should successfully run the real AuthService.createTourist with mock DB", async () => { + createTouristSpy.mockRestore(); + resetDbCache(); + const mockDbForTourist = { + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => Promise.resolve([]), + }), + }), + }), + insert: (table: unknown) => ({ + values: () => { + if (table === users) { + return { + returning: () => + Promise.resolve([ + { + id: "new-user-uuid", + email: null, + alias: "New Explorer", + role: "TOURIST", + isActive: true, + zzz_failed_login_attempts: 0, + zzz_last_login_at: null, + zzzCreatedAt: new Date(), + zzzUpdatedAt: new Date(), + }, + ]), + }; + } else { + return Promise.resolve(); + } + }, + }), + } as unknown as ReturnType; + + createDbSpy.mockReturnValue(mockDbForTourist); + + const res = await testApp.request( + "/v1/auth/tourist/create", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + alias: "New Explorer", + role: "TOURIST", + email: null, + firstName: null, + lastName: null, + phoneNumber: null, + }), + }, + TEST_ENV, + ); + + expect(res.status).toBe(200); + const body = (await res.json()) as { + accessToken: string; + refreshToken: string; + user: { role: string; alias: string }; + }; + expect(body.accessToken).toBeDefined(); + expect(body.refreshToken).toBeDefined(); + expect(body.user.role).toBe("TOURIST"); + expect(body.user.alias).toBe("New Explorer"); + + // Restore standard mocks for subsequent tests/hooks + createDbSpy.mockReturnValue({} as unknown as ReturnType); + resetDbCache(); + }); }); afterAll(() => { diff --git a/apps/backend/src/routes/orders.test.ts b/apps/backend/src/routes/orders.test.ts index b6c2037..6c72a69 100644 --- a/apps/backend/src/routes/orders.test.ts +++ b/apps/backend/src/routes/orders.test.ts @@ -6,6 +6,7 @@ import { resetDbCache, dbMiddleware } from "../middleware/db"; import { authMiddleware } from "../middleware/auth"; import { ordersRouter } from "./orders"; import type { AppEnv } from "../config/env"; +import { reservations, productCategories, ventures, products, orders } from "../db/schema"; const TEST_ENV = { DATABASE_URL: "postgres://localhost:5432/db", @@ -29,7 +30,7 @@ describe("Orders API", () => { const mockOrder = { zzz_id: "550e8400-e29b-41d4-a716-446655440000", zzz_reservation_id: "550e8400-e29b-41d4-a716-446655440001", - zzz_catalog_type_id: 1, + zzz_product_category_id: 1, zzz_confirmed_venture_id: null, zzz_notes: null, zzz_global_status: "SEARCHING", @@ -48,6 +49,8 @@ describe("Orders API", () => { zzz_id: "550e8400-e29b-41d4-a716-446655440001", zzz_user_id: "test-user-id", zzz_status: "CREATED", + zzz_guest_count: 2, + zzz_service_at: new Date("2026-05-25T12:00:00.000Z"), }; const createListBuilder = (result: unknown[]) => { @@ -76,33 +79,73 @@ describe("Orders API", () => { * Creates a mock Db object that wraps transaction for OrderService.create/updateStatus. * Uses call-count-based response switching for select/insert chains. */ - const createTxDb = (selectResults: unknown[][], insertResults?: unknown[][]) => { - let selectIdx = 0; - let insertIdx = 0; - + const createTxDb = (selectResults: unknown[][], _insertResults?: unknown[][]) => { const mockTx = { - select: () => ({ - from: () => ({ - where: () => { - const result = selectResults[selectIdx] ?? []; - if (selectIdx < selectResults.length - 1) selectIdx++; - return createWhereChain(result); - }, - // For calls without .where() (e.g., select from products before inArray) - then: (resolve: (v: unknown) => unknown) => resolve(selectResults[selectIdx] ?? []), - }), + select: (_fields?: unknown) => ({ + from: (table: unknown) => { + return { + innerJoin: (_joinTable: unknown, _joinCond: unknown) => { + return { + where: () => { + return Promise.resolve([{ occupied: 0 }]); + }, + }; + }, + where: () => { + let result: unknown[] = []; + if (table === reservations) { + result = selectResults[0] ?? [mockReservation]; + } else if (table === productCategories) { + result = [{ zzz_id: 1, zzz_project_id: 1 }]; + } else if (table === ventures) { + result = [ + { + id: 1, + name: "Venture 1", + zzz_max_capacity: 10, + zzz_cascade_order: 0, + zzz_is_active: true, + zzz_is_paused: false, + zzz_product_category_id: 1, + zzz_project_id: 1, + }, + ]; + } else if (table === products) { + result = selectResults[1] ?? [{ zzz_id: 1, zzz_price: 25.0 }]; + } else { + result = selectResults[0] ?? [mockOrder]; + } + return createWhereChain(result); + }, + orderBy: () => createWhereChain(selectResults[0] ?? [mockOrder]), + limit: () => createWhereChain(selectResults[0] ?? [mockOrder]), + then: (resolve: (v: unknown) => unknown) => resolve(selectResults[0] ?? []), + }; + }, }), - insert: () => ({ - values: () => ({ + insert: (insertTable: unknown) => ({ + values: (vals: unknown) => ({ returning: () => { - const result = insertResults?.[insertIdx] ?? [mockOrder]; - if (insertIdx < (insertResults?.length ?? 1) - 1) insertIdx++; - return Promise.resolve(result); + if (insertTable === orders) { + const createdOrder = { + ...mockOrder, + ...(vals as Record), + zzz_id: mockOrder.zzz_id, + }; + return Promise.resolve([createdOrder]); + } else { + const items = Array.isArray(vals) ? (vals as unknown[]) : [vals]; + const createdItems = items.map((item: unknown, i: number) => ({ + ...(item as Record), + zzz_id: `item-${i}`, + })); + return Promise.resolve(createdItems); + } }, }), }), update: () => ({ - set: () => ({ + set: (_vals: unknown) => ({ where: () => ({ returning: () => { const result = selectResults[0] ?? [mockOrder]; @@ -141,7 +184,7 @@ describe("Orders API", () => { describe("POST /v1/orders", () => { const validBody = { zzz_reservation_id: "550e8400-e29b-41d4-a716-446655440001", - zzz_catalog_type_id: 1, + zzz_product_category_id: 1, zzz_notify_whatsapp: false, zzz_items: [{ zzz_catalog_item_id: 1, zzz_quantity: 2 }], }; @@ -181,7 +224,7 @@ describe("Orders API", () => { expect(res.status).toBe(201); const body = await res.json(); - expect(body.zzz_global_status).toBe("SEARCHING"); + expect(body.zzz_global_status).toBe("OFFER_PENDING"); expect(body.zzz_items).toHaveLength(1); }); diff --git a/apps/backend/src/routes/ventures.test.ts b/apps/backend/src/routes/ventures.test.ts index b7327ce..28b28a8 100644 --- a/apps/backend/src/routes/ventures.test.ts +++ b/apps/backend/src/routes/ventures.test.ts @@ -88,6 +88,7 @@ describe("Ventures API", () => { zzz_project_id: 1, zzz_max_capacity: 10, zzz_is_active: true, + zzz_product_category_id: 1, }; const res = await app.request( @@ -148,6 +149,7 @@ describe("Ventures API", () => { name: "Failing Venture", ownerId: "123e4567-e89b-12d3-a456-426614174000", zzz_project_id: 1, + zzz_product_category_id: 1, }; const res = await app.request( diff --git a/apps/backend/src/services/order.service.test.ts b/apps/backend/src/services/order.service.test.ts index 5aa6c94..f42c88f 100644 --- a/apps/backend/src/services/order.service.test.ts +++ b/apps/backend/src/services/order.service.test.ts @@ -1,12 +1,20 @@ import { describe, expect, it } from "bun:test"; import { OrderService, OrderServiceError } from "./order.service"; import { type Db } from "../db"; +import { + reservations, + productCategories, + ventures, + products, + orders, + orderItems, +} from "../db/schema"; describe("OrderService", () => { const mockOrder = { zzz_id: "550e8400-e29b-41d4-a716-446655440000", zzz_reservation_id: "550e8400-e29b-41d4-a716-446655440001", - zzz_catalog_type_id: 1, + zzz_product_category_id: 1, zzz_confirmed_venture_id: null, zzz_notes: null, zzz_global_status: "SEARCHING" as const, @@ -25,6 +33,12 @@ describe("OrderService", () => { zzz_id: "550e8400-e29b-41d4-a716-446655440001", zzz_user_id: "user-1", zzz_status: "CREATED" as const, + zzz_guest_count: 2, + zzz_service_at: new Date("2026-05-25T12:00:00.000Z"), + zzz_time_of_day: "LUNCH" as const, + zzzCreatedAt: new Date("2026-05-24T00:00:00.000Z"), + zzzUpdatedAt: new Date("2026-05-24T00:00:00.000Z"), + zzzDeletedAt: null as Date | null, }; // --- Helpers --- @@ -136,69 +150,182 @@ describe("OrderService", () => { }); describe("getAll", () => { - it("should return all orders for ADMIN", async () => { - const mockDb = { + // Shared mockDB factory for getAll tests. + // Returns a mock that handles: + // - orders table → [mockOrder] + // - reservations table → [mockReservation] + // - orderItems table → [mockOrderItem] + // - products table → [mockCatalogProduct] + const mockCatalogProduct = { + zzz_id: 1, + zzz_product_category_id: 1, + zzz_name_i18n: { en: "Test Product", es: "Producto de Prueba" }, + zzz_description_i18n: null as Record | null, + zzz_price: 25_000, + zzz_max_participants: 4, + zzz_image_url: null as string | null, + zzz_global_pause: false, + zzz_service_moments: null as string[] | null, + zzzCreatedAt: new Date("2026-05-24T00:00:00.000Z"), + zzzUpdatedAt: new Date("2026-05-24T00:00:00.000Z"), + zzzDeletedAt: null as Date | null, + }; + + const mockOrderItem = { + zzz_id: "item-1", + zzz_order_id: mockOrder.zzz_id, + zzz_catalog_item_id: 1, + zzz_quantity: 2, + zzz_price: 25_000, + zzz_notes: null as string | null, + zzzCreatedAt: new Date("2026-05-24T00:00:00.000Z"), + zzzUpdatedAt: new Date("2026-05-24T00:00:00.000Z"), + zzzDeletedAt: null as Date | null, + }; + + const mockDbFactory = () => + ({ select: () => ({ - from: () => createListBuilder([mockOrder]), + from: (table: unknown) => { + if (table === orderItems) { + return createListBuilder([mockOrderItem]); + } + if (table === products) { + return createListBuilder([mockCatalogProduct]); + } + if (table === reservations) { + return createListBuilder([mockReservation]); + } + return createListBuilder([mockOrder]); + }, }), - } as unknown as Db; + }) as unknown as Db; + + const enrichedItem = { + ...mockOrderItem, + zzz_price: 25_000, + zzz_catalog_item: { + zzz_id: mockCatalogProduct.zzz_id, + zzz_product_category_id: mockCatalogProduct.zzz_product_category_id, + zzz_name_i18n: mockCatalogProduct.zzz_name_i18n, + zzz_description_i18n: mockCatalogProduct.zzz_description_i18n, + zzz_price: mockCatalogProduct.zzz_price, + zzz_max_participants: mockCatalogProduct.zzz_max_participants, + zzz_image_url: mockCatalogProduct.zzz_image_url, + zzz_global_pause: mockCatalogProduct.zzz_global_pause, + zzz_service_moments: mockCatalogProduct.zzz_service_moments, + }, + }; - const result = await OrderService.getAll(mockDb, {}, "ADMIN" as never, "user-1"); - expect(result).toEqual([mockOrder]); + const expectedOrder = { + ...mockOrder, + zzz_reservation: mockReservation, + zzz_items: [enrichedItem], + }; + + it("should return all orders for ADMIN", async () => { + const result = await OrderService.getAll(mockDbFactory(), {}, "ADMIN" as never, "user-1"); + expect(result).toEqual([expectedOrder]); }); it("should filter by reservation for TOURIST", async () => { - const mockDb = { - select: () => ({ - from: () => createListBuilder([mockOrder]), - }), - } as unknown as Db; - - const result = await OrderService.getAll(mockDb, {}, "TOURIST" as never, "user-1"); - expect(result).toEqual([mockOrder]); + const result = await OrderService.getAll(mockDbFactory(), {}, "TOURIST" as never, "user-1"); + expect(result).toEqual([expectedOrder]); }); it("should filter by venture for ENTREPRENEUR", async () => { - const mockDb = { - select: () => ({ - from: () => createListBuilder([mockOrder]), - }), - } as unknown as Db; - - const result = await OrderService.getAll(mockDb, {}, "ENTREPRENEUR" as never, "user-1"); - expect(result).toEqual([mockOrder]); + const result = await OrderService.getAll( + mockDbFactory(), + {}, + "ENTREPRENEUR" as never, + "user-1", + ); + expect(result).toEqual([expectedOrder]); }); it("should apply status filter", async () => { - const mockDb = { - select: () => ({ - from: () => createListBuilder([mockOrder]), - }), - } as unknown as Db; - const result = await OrderService.getAll( - mockDb, + mockDbFactory(), { status: "SEARCHING" }, "ADMIN" as never, "user-1", ); - expect(result).toEqual([mockOrder]); + expect(result).toEqual([expectedOrder]); }); it("should apply reservation_id filter", async () => { - const mockDb = { - select: () => ({ - from: () => createListBuilder([mockOrder]), - }), - } as unknown as Db; - const result = await OrderService.getAll( - mockDb, + mockDbFactory(), { reservation_id: "res-1" }, "ADMIN" as never, "user-1", ); - expect(result).toEqual([mockOrder]); + expect(result).toEqual([expectedOrder]); + }); + + it("should return empty array when no orders match", async () => { + const emptyDb = { + select: () => ({ + from: () => createListBuilder([]), + }), + } as unknown as Db; + + const result = await OrderService.getAll(emptyDb, {}, "ADMIN" as never, "user-1"); + expect(result).toEqual([]); + }); + + it("should return order with empty zzz_items when no order_items exist", async () => { + const noItemsDb = { + select: () => ({ + from: (table: unknown) => { + if (table === orderItems) { + return createListBuilder([]); + } + if (table === products) { + return createListBuilder([]); + } + if (table === reservations) { + return createListBuilder([mockReservation]); + } + return createListBuilder([mockOrder]); + }, + }), + } as unknown as Db; + + const result = await OrderService.getAll(noItemsDb, {}, "ADMIN" as never, "user-1"); + expect(result).toHaveLength(1); + expect(result[0].zzz_items).toEqual([]); + expect(result[0].zzz_reservation).toEqual(mockReservation); + }); + + it("should set zzz_catalog_item to undefined when product not found in catalog", async () => { + const unknownProductItem = { + ...mockOrderItem, + zzz_catalog_item_id: 999, + }; + + const missingProductDb = { + select: () => ({ + from: (table: unknown) => { + if (table === orderItems) { + return createListBuilder([unknownProductItem]); + } + if (table === products) { + return createListBuilder([]); + } + if (table === reservations) { + return createListBuilder([mockReservation]); + } + return createListBuilder([mockOrder]); + }, + }), + } as unknown as Db; + + const result = await OrderService.getAll(missingProductDb, {}, "ADMIN" as never, "user-1"); + expect(result).toHaveLength(1); + expect(result[0].zzz_items).toHaveLength(1); + expect(result[0].zzz_items[0].zzz_catalog_item_id).toBe(999); + expect(result[0].zzz_items[0].zzz_catalog_item).toBeUndefined(); }); }); @@ -297,7 +424,7 @@ describe("OrderService", () => { describe("create", () => { const validInput = { zzz_reservation_id: "550e8400-e29b-41d4-a716-446655440001", - zzz_catalog_type_id: 1, + zzz_product_category_id: 1, zzz_notify_whatsapp: false, zzz_items: [ { zzz_catalog_item_id: 1, zzz_quantity: 2 }, @@ -308,53 +435,84 @@ describe("OrderService", () => { const mockProduct1 = { zzz_id: 1, zzz_price: 25.0 }; const mockProduct2 = { zzz_id: 2, zzz_price: 15.0 }; - const mockCreatedItems = [ - { - zzz_id: "item-1", - zzz_order_id: mockOrder.zzz_id, - zzz_catalog_item_id: 1, - zzz_quantity: 2, - zzz_price: 25.0, - }, - { - zzz_id: "item-2", - zzz_order_id: mockOrder.zzz_id, - zzz_catalog_item_id: 2, - zzz_quantity: 1, - zzz_price: 15.0, - }, - ]; - const createTxDb = ( reservationResult: unknown[], productResult: unknown[], - options?: { orderResult?: unknown[]; itemResult?: unknown[] }, + options?: { + orderResult?: unknown[]; + itemResult?: unknown[]; + categoryResult?: unknown[]; + venturesResult?: unknown[]; + occupiedCount?: number; + }, ) => { - const selectResults = [reservationResult, productResult]; - const insertResults = [ - options?.orderResult ?? [mockOrder], - options?.itemResult ?? mockCreatedItems, - ]; - let selectIdx = 0; - let insertIdx = 0; - const mockTx = { - select: () => ({ - from: () => ({ - where: () => { - const result = selectResults[selectIdx] ?? []; - selectIdx++; - return createWhereChain(result); + select: (_fields?: unknown) => { + return { + from: (table: unknown) => { + return { + innerJoin: (_joinTable: unknown, _joinCond: unknown) => { + return { + where: () => { + return Promise.resolve([{ occupied: options?.occupiedCount ?? 0 }]); + }, + }; + }, + where: () => { + let result: unknown[] = []; + if (table === reservations) { + result = reservationResult; + } else if (table === productCategories) { + result = options?.categoryResult ?? [{ zzz_id: 1, zzz_project_id: 1 }]; + } else if (table === ventures) { + const list = (options?.venturesResult as Record[]) ?? [ + { + id: 1, + name: "Venture 1", + zzz_max_capacity: 10, + zzz_cascade_order: 0, + zzz_is_active: true, + zzz_is_paused: false, + zzz_product_category_id: 1, + zzz_project_id: 1, + }, + ]; + result = list.filter( + (v: Record) => + v.zzz_is_active === true && v.zzz_is_paused === false, + ); + } else if (table === products) { + result = productResult; + } + return createWhereChain(result); + }, + }; }, - }), - }), - insert: () => { - const idx = insertIdx; - insertIdx++; + }; + }, + insert: (insertTable: unknown) => { return { - values: () => ({ - returning: () => Promise.resolve(insertResults[idx] ?? []), - }), + values: (vals: unknown) => { + return { + returning: () => { + if (insertTable === orders) { + const createdOrder = { + ...mockOrder, + ...(vals as Record), + zzz_id: mockOrder.zzz_id, + }; + return Promise.resolve([createdOrder]); + } else { + const items = Array.isArray(vals) ? (vals as unknown[]) : [vals]; + const createdItems = items.map((item: unknown, i: number) => ({ + ...(item as Record), + zzz_id: `item-${i}`, + })); + return Promise.resolve(createdItems); + } + }, + }; + }, }; }, }; @@ -364,13 +522,32 @@ describe("OrderService", () => { } as unknown as Db; }; - it("should create order + items in a transaction", async () => { + it("should create order + items in a transaction and assign to available venture", async () => { const mockDb = createTxDb([mockReservation], [mockProduct1, mockProduct2]); const result = await OrderService.create(mockDb, "user-1", validInput); expect(result).toBeDefined(); - expect(result.zzz_global_status).toBe("SEARCHING"); + expect(result.zzz_global_status).toBe("OFFER_PENDING"); + expect(result.zzz_current_offer_venture_id).toBe(1); + expect(result.zzz_items).toHaveLength(2); + }); + + it("should preserve zzz_notes on each item when creating an order", async () => { + const inputWithNotes = { + ...validInput, + zzz_items: [ + { zzz_catalog_item_id: 1, zzz_quantity: 2, zzz_notes: "Sin cebolla" }, + { zzz_catalog_item_id: 2, zzz_quantity: 1, zzz_notes: "Bien cocida" }, + ], + }; + + const mockDb = createTxDb([mockReservation], [mockProduct1, mockProduct2]); + + const result = await OrderService.create(mockDb, "user-1", inputWithNotes); + expect(result.zzz_items).toHaveLength(2); + expect(result.zzz_items[0].zzz_notes).toBe("Sin cebolla"); + expect(result.zzz_items[1].zzz_notes).toBe("Bien cocida"); }); it("should reject if reservation does not exist", async () => { @@ -404,46 +581,7 @@ describe("OrderService", () => { { zzz_id: 1, zzz_price: 25.0 }, { zzz_id: 2, zzz_price: 15.0 }, ]; - let capturedItemValues: unknown[] = []; - let insertCounter = 0; - - const mockTx = { - select: () => ({ - from: () => ({ - where: () => { - // First call returns reservation, second returns products - const result = insertCounter === 0 ? [mockReservation] : priceProducts; - insertCounter++; - return createWhereChain(result); - }, - }), - }), - insert: () => { - const isFirstInsert = insertCounter < 2; // After 2 selects, first insert - return { - values: (vals: unknown) => { - if (!isFirstInsert) { - capturedItemValues = Array.isArray(vals) ? vals : []; - } - return { - returning: () => - Promise.resolve( - isFirstInsert - ? [mockOrder] - : capturedItemValues.map((v: unknown, i: number) => ({ - ...(v as object), - zzz_id: `item-${i}`, - })), - ), - }; - }, - }; - }, - }; - - const mockDb = { - transaction: async (fn: (tx: typeof mockTx) => Promise) => fn(mockTx), - } as unknown as Db; + const mockDb = createTxDb([mockReservation], priceProducts); const result = await OrderService.create(mockDb, "user-1", validInput); @@ -459,6 +597,67 @@ describe("OrderService", () => { "Catalog items not found", ); }); + + it("should auto-expire order if no ventures are found", async () => { + const mockDb = createTxDb([mockReservation], [mockProduct1, mockProduct2], { + venturesResult: [], + }); + + const result = await OrderService.create(mockDb, "user-1", validInput); + expect(result.zzz_global_status).toBe("EXPIRED"); + expect(result.zzz_cancel_reason).toBe("NO_VENTURE_AVAILABLE"); + expect(result.zzz_current_offer_venture_id).toBeNull(); + }); + + it("should skip paused/inactive ventures and match active ones", async () => { + const customVentures = [ + { + id: 1, + name: "Venture 1 (Paused)", + zzz_max_capacity: 10, + zzz_cascade_order: 0, + zzz_is_active: true, + zzz_is_paused: true, + zzz_product_category_id: 1, + zzz_project_id: 1, + }, + { + id: 2, + name: "Venture 2 (Active)", + zzz_max_capacity: 10, + zzz_cascade_order: 1, + zzz_is_active: true, + zzz_is_paused: false, + zzz_product_category_id: 1, + zzz_project_id: 1, + }, + ]; + const mockDb = createTxDb([mockReservation], [mockProduct1, mockProduct2], { + venturesResult: customVentures, + }); + + const result = await OrderService.create(mockDb, "user-1", validInput); + expect(result.zzz_global_status).toBe("OFFER_PENDING"); + // Should match venture 2 since venture 1 is paused + expect(result.zzz_current_offer_venture_id).toBe(2); + }); + + it("should auto-expire order if venture is over capacity", async () => { + const customReservation = { + ...mockReservation, + zzz_guest_count: 5, + }; + // Guest count is 5, venture capacity is 10. + // If occupied count is 6, total is 11 > 10, so capacity check fails! + const mockDb = createTxDb([customReservation], [mockProduct1, mockProduct2], { + occupiedCount: 6, + }); + + const result = await OrderService.create(mockDb, "user-1", validInput); + expect(result.zzz_global_status).toBe("EXPIRED"); + expect(result.zzz_cancel_reason).toBe("NO_VENTURE_AVAILABLE"); + expect(result.zzz_current_offer_venture_id).toBeNull(); + }); }); describe("updateStatus", () => { diff --git a/apps/backend/src/services/order.service.ts b/apps/backend/src/services/order.service.ts index 94072b3..f1969ce 100644 --- a/apps/backend/src/services/order.service.ts +++ b/apps/backend/src/services/order.service.ts @@ -1,7 +1,13 @@ import { eq, and, desc, inArray, sql, type SQL } from "drizzle-orm"; import { type Db } from "../db"; -import { orders, orderItems, reservations } from "../db/schema"; -import { products } from "../db/schema/products"; +import { + orders, + orderItems, + reservations, + ventures, + productCategories, + products, +} from "../db/schema"; import type { CreateOrderInput, UpdateOrderInput, @@ -79,15 +85,70 @@ export class OrderService { ); } + // 1.5 Validate category exists + const [category] = await tx + .select() + .from(productCategories) + .where(eq(productCategories.zzz_id, input.zzz_product_category_id)) + .limit(SINGLE_RESULT_LIMIT); + + if (!category) { + throw new OrderServiceError("Not Found", "Product category not found", HTTP_NOT_FOUND); + } + + // Fetch active, unpaused ventures for categoryId ordered by cascade_order ASC + const availableVentures = await tx + .select() + .from(ventures) + .where( + and( + eq(ventures.zzz_product_category_id, input.zzz_product_category_id), + eq(ventures.zzz_project_id, category.zzz_project_id), + eq(ventures.zzz_is_active, true), + eq(ventures.zzz_is_paused, false), + ), + ) + .orderBy(ventures.zzz_cascade_order); + + // Find matching venture based on capacity + let matchedVentureId: number | null = null; + for (const venture of availableVentures) { + const [occupationRow] = await tx + .select({ + occupied: sql`COALESCE(SUM(${reservations.zzz_guest_count}), 0)::int`, + }) + .from(orders) + .innerJoin(reservations, eq(orders.zzz_reservation_id, reservations.zzz_id)) + .where( + and( + eq(orders.zzz_confirmed_venture_id, venture.id), + eq(reservations.zzz_service_at, reservation.zzz_service_at), + eq(orders.zzz_global_status, "CONFIRMED"), + ), + ); + + const occupied = occupationRow?.occupied ?? 0; + if (occupied + reservation.zzz_guest_count <= venture.zzz_max_capacity) { + matchedVentureId = venture.id; + break; + } + } + + // Determine initial order fields + const status: OrderStatus = matchedVentureId !== null ? "OFFER_PENDING" : "EXPIRED"; + const cancelReason = matchedVentureId === null ? ("NO_VENTURE_AVAILABLE" as const) : null; + // 2. Insert the order row const [order] = await tx .insert(orders) .values({ zzz_reservation_id: input.zzz_reservation_id, - zzz_catalog_type_id: input.zzz_catalog_type_id, + zzz_product_category_id: input.zzz_product_category_id, zzz_notes: input.zzz_notes ?? null, zzz_notify_whatsapp: input.zzz_notify_whatsapp ?? false, - zzz_global_status: "SEARCHING", + zzz_global_status: status, + zzz_current_offer_venture_id: matchedVentureId, + zzz_cancel_reason: cancelReason, }) .returning(); @@ -114,12 +175,13 @@ export class OrderService { zzz_catalog_item_id: item.zzz_catalog_item_id, zzz_quantity: item.zzz_quantity, zzz_price: priceMap.get(item.zzz_catalog_item_id)!, + zzz_notes: item.zzz_notes ?? null, })); // 4. Insert order items const insertedItems = await tx.insert(orderItems).values(itemsToInsert).returning(); - return { ...order, zzz_items: insertedItems }; + return { ...order, zzz_items: insertedItems, zzz_reservation: reservation }; }); } @@ -184,7 +246,83 @@ export class OrderService { const query = db.select().from(orders); const finalQuery = conditions.length > 0 ? query.where(and(...conditions)) : query; - return finalQuery.orderBy(desc(orders.zzzCreatedAt)).limit(limit).offset(offset); + const results = await finalQuery.orderBy(desc(orders.zzzCreatedAt)).limit(limit).offset(offset); + + // Populate zzz_reservation for each order so the mobile app can filter + // by zzz_service_at and group by moment without extra API calls. + const reservationIds = results + .map((o) => o.zzz_reservation_id) + .filter((id): id is string => !!id); + + const reservationMap = new Map(); + if (reservationIds.length > 0) { + const reservationRows = await db + .select() + .from(reservations) + .where(inArray(reservations.zzz_id, reservationIds)); + for (const r of reservationRows) { + reservationMap.set(r.zzz_id, r); + } + } + + // Populate zzz_items with product catalog data for the ReservartionCard display. + const orderIds = results.map((o) => o.zzz_id); + const itemsByOrderId = new Map(); + const productMap = new Map(); + + if (orderIds.length > 0) { + const itemRows = await db + .select() + .from(orderItems) + .where(inArray(orderItems.zzz_order_id, orderIds)); + + const catalogIds = [...new Set(itemRows.map((i) => i.zzz_catalog_item_id))]; + if (catalogIds.length > 0) { + const productRows = await db + .select() + .from(products) + .where(inArray(products.zzz_id, catalogIds)); + for (const p of productRows) { + productMap.set(p.zzz_id, p); + } + } + + for (const item of itemRows) { + const existing = itemsByOrderId.get(item.zzz_order_id) ?? []; + existing.push(item); + itemsByOrderId.set(item.zzz_order_id, existing); + } + } + + return results.map((order) => { + const rawItems = itemsByOrderId.get(order.zzz_id) ?? []; + const enrichedItems = rawItems.map((item) => { + const product = productMap.get(item.zzz_catalog_item_id); + return { + ...item, + zzz_price: Number(item.zzz_price), + zzz_catalog_item: product + ? { + zzz_id: product.zzz_id, + zzz_product_category_id: product.zzz_product_category_id, + zzz_name_i18n: product.zzz_name_i18n, + zzz_description_i18n: product.zzz_description_i18n, + zzz_price: Number(product.zzz_price), + zzz_max_participants: product.zzz_max_participants, + zzz_image_url: product.zzz_image_url, + zzz_global_pause: product.zzz_global_pause, + zzz_service_moments: product.zzz_service_moments, + } + : undefined, + }; + }); + + return { + ...order, + zzz_items: enrichedItems, + zzz_reservation: reservationMap.get(order.zzz_reservation_id) ?? null, + }; + }); } // -- UPDATE metadata -- diff --git a/apps/backend/src/services/venture.service.test.ts b/apps/backend/src/services/venture.service.test.ts index 03f0bd8..c4a65ea 100644 --- a/apps/backend/src/services/venture.service.test.ts +++ b/apps/backend/src/services/venture.service.test.ts @@ -12,6 +12,7 @@ describe("VentureService", () => { zzz_cascade_order: 1, zzz_is_paused: false, zzz_is_active: true, + zzz_product_category_id: 1, zzzCreatedAt: new Date(), zzzUpdatedAt: new Date(), zzzDeletedAt: null as Date | null, @@ -72,6 +73,7 @@ describe("VentureService", () => { zzz_cascade_order: 1, zzz_is_paused: false, zzz_is_active: true, + zzz_product_category_id: 1, }); expect(result).toEqual(mockVenture); }); diff --git a/apps/mobile/src/__tests__/login.test.tsx b/apps/mobile/src/__tests__/login.test.tsx new file mode 100644 index 0000000..7f2441c --- /dev/null +++ b/apps/mobile/src/__tests__/login.test.tsx @@ -0,0 +1,86 @@ +import { render, screen, fireEvent, waitFor, act } from "./utils/test-utils"; +import LoginScreen from "../app/tourist/login"; +import { useAuthStore } from "../stores/auth.store"; +import { router } from "expo-router"; + +jest.mock("../stores/auth.store"); + +const ALIAS_PLACEHOLDER_KEY = "login.alias_placeholder"; + +const mockRegister = jest.fn(); + +const setupAuthMock = () => { + const mockedStore = jest.mocked(useAuthStore); + // The component reads register via useAuthStore.getState().register (not through a selector) + mockedStore.getState = jest.fn().mockReturnValue({ register: mockRegister }); + // Selector mock for any useAuthStore(selector) call in child components + mockedStore.mockImplementation((selector: unknown) => { + const state = { register: mockRegister, isAuthenticated: false, currentUser: null }; + return typeof selector === "function" ? selector(state) : state; + }); +}; + +describe("LoginScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + setupAuthMock(); + }); + + it("renders the alias input and submit button", () => { + render(); + expect(screen.getByPlaceholderText(ALIAS_PLACEHOLDER_KEY)).toBeTruthy(); + expect(screen.getByTestId("login-submit")).toBeTruthy(); + }); + + it("shows validation error when submitting with empty alias", async () => { + render(); + fireEvent.press(screen.getByTestId("login-submit")); + await waitFor(() => { + expect(screen.getByText("login.alias_required")).toBeTruthy(); + }); + expect(mockRegister).not.toHaveBeenCalled(); + }); + + it("calls register with correct payload and redirects on success", async () => { + mockRegister.mockResolvedValueOnce(undefined); + render(); + + fireEvent.changeText(screen.getByPlaceholderText(ALIAS_PLACEHOLDER_KEY), "Familia Gómez"); + fireEvent.press(screen.getByTestId("login-submit")); + + await waitFor(() => { + expect(mockRegister).toHaveBeenCalledWith( + expect.objectContaining({ alias: "Familia Gómez" }), + ); + expect(router.replace).toHaveBeenCalledWith("/tourist"); + }); + }); + + it("shows submission error when register rejects", async () => { + mockRegister.mockRejectedValueOnce(new Error("Network error")); + render(); + + fireEvent.changeText(screen.getByPlaceholderText(ALIAS_PLACEHOLDER_KEY), "Familia Gómez"); + fireEvent.press(screen.getByTestId("login-submit")); + + await waitFor(() => { + expect(screen.getByTestId("registration-error")).toBeTruthy(); + }); + expect(router.replace).not.toHaveBeenCalled(); + }); + + it("does not call register a second time while a submission is in flight", async () => { + let resolveRegister!: () => void; + mockRegister.mockReturnValueOnce(new Promise((res) => (resolveRegister = res))); + + render(); + fireEvent.changeText(screen.getByPlaceholderText(ALIAS_PLACEHOLDER_KEY), "Familia Gómez"); + + fireEvent.press(screen.getByTestId("login-submit")); + fireEvent.press(screen.getByTestId("login-submit")); // should be no-op while pending + + await act(async () => resolveRegister()); + + expect(mockRegister).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/mobile/src/app/entrepreneur/__tests__/agenda.test.tsx b/apps/mobile/src/app/entrepreneur/__tests__/agenda.test.tsx index 51ea8c0..01363d6 100644 --- a/apps/mobile/src/app/entrepreneur/__tests__/agenda.test.tsx +++ b/apps/mobile/src/app/entrepreneur/__tests__/agenda.test.tsx @@ -62,12 +62,14 @@ describe("AgendaScreen", () => { }); it("should render reservation notes in orders", () => { - const mockId = (n: number): string => `00000000-0000-0000-0000-${String(n).padStart(12, "0")}`; + const UUID_PAD_LENGTH = 12; + const mockId = (n: number): string => + `00000000-0000-0000-0000-${String(n).padStart(UUID_PAD_LENGTH, "0")}`; const mockNotes = "Una persona es hipertensa, por favor cocinar sin sal."; const mockOrder: Order = { zzz_id: mockId(9), zzz_reservation_id: mockId(1), - zzz_catalog_type_id: 1, + zzz_product_category_id: 1, zzz_global_status: "CONFIRMED", zzz_confirmed_venture_id: 1, zzz_notes: mockNotes, diff --git a/apps/mobile/src/app/tourist/__tests__/login.test.tsx b/apps/mobile/src/app/tourist/__tests__/login.test.tsx new file mode 100644 index 0000000..59cd212 --- /dev/null +++ b/apps/mobile/src/app/tourist/__tests__/login.test.tsx @@ -0,0 +1,117 @@ +import React from "react"; +import { render, fireEvent, screen, waitFor } from "@testing-library/react-native"; +import LoginScreen from "../login"; +import { useAuthStore } from "../../../stores/auth.store"; +import { UserRole } from "@repo/shared"; + +// Mock i18n +jest.mock("../../../hooks/useI18n", () => ({ + useTranslations: () => ({ + t: (key: string, _params?: Record) => { + const translations: Record = { + "login.alias_required": "Alias is required", + "login.alias_label": "Alias", + "login.alias_placeholder": "Your alias", + "login.whatsapp_label": "WhatsApp", + "login.whatsapp_placeholder": "+54...", + "login.first_name_label": "First name", + "login.last_name_label": "Last name", + "login.optional_section": "OPTIONAL", + "login.welcome_title": "Welcome", + "login.welcome_subtitle": "Sign up", + "login.submit_button": "Start", + "accessibility.login_submit_hint": "Submit form", + "login.errors.registration_failed": + "Registration failed. Please check your connection and try again.", + }; + return translations[key] || key; + }, + }), +})); + +const mockReplace = jest.fn(); +jest.mock("expo-router", () => ({ + useRouter: () => ({ + replace: mockReplace, + push: jest.fn(), + back: jest.fn(), + }), +})); + +describe("LoginScreen", () => { + beforeEach(() => { + jest.clearAllMocks(); + useAuthStore.setState({ + currentUser: null, + accessToken: null, + isAuthenticated: false, + isLoading: false, + error: null, + userRole: UserRole.TOURIST, + }); + }); + + it("should show error message when registration fails (currently broken)", async () => { + const registerSpy = jest + .spyOn(useAuthStore.getState(), "register") + .mockRejectedValue(new Error("errors.auth.connection_failed")); + + render(); + + // Find alias input by placeholder + const aliasInput = screen.getByPlaceholderText("Your alias"); + fireEvent.changeText(aliasInput, "New Explorer"); + + // Submit the form + const submitButton = screen.getByText("Start"); + fireEvent.press(submitButton); + + // Wait for register to be called + await waitFor(() => { + expect(registerSpy).toHaveBeenCalled(); + }); + + // After the fix, the login screen should display the translated error message + const errorMessage = screen.queryByText( + "Registration failed. Please check your connection and try again.", + ); + expect(errorMessage).not.toBeNull(); + + registerSpy.mockRestore(); + }); + + it("should not redirect when registration fails", async () => { + jest + .spyOn(useAuthStore.getState(), "register") + .mockRejectedValue(new Error("errors.auth.connection_failed")); + + render(); + + const aliasInput = screen.getByPlaceholderText("Your alias"); + fireEvent.changeText(aliasInput, "New Explorer"); + + const submitButton = screen.getByText("Start"); + fireEvent.press(submitButton); + + // Wait for the async operation to settle + await waitFor(() => { + expect(mockReplace).not.toHaveBeenCalled(); + }); + }); + + it("should redirect to /tourist on successful registration", async () => { + jest.spyOn(useAuthStore.getState(), "register").mockResolvedValue(undefined); + + render(); + + const aliasInput = screen.getByPlaceholderText("Your alias"); + fireEvent.changeText(aliasInput, "New Explorer"); + + const submitButton = screen.getByText("Start"); + fireEvent.press(submitButton); + + await waitFor(() => { + expect(mockReplace).toHaveBeenCalledWith("/tourist"); + }); + }); +}); diff --git a/apps/mobile/src/app/tourist/__tests__/orders.test.tsx b/apps/mobile/src/app/tourist/__tests__/orders.test.tsx index f62ff32..b915888 100644 --- a/apps/mobile/src/app/tourist/__tests__/orders.test.tsx +++ b/apps/mobile/src/app/tourist/__tests__/orders.test.tsx @@ -90,12 +90,14 @@ describe("OrderScreen (Tourist)", () => { }); it("should render reservation notes in active orders", () => { - const mockId = (n: number): string => `00000000-0000-0000-0000-${String(n).padStart(12, "0")}`; + const UUID_PAD_LENGTH = 12; + const mockId = (n: number): string => + `00000000-0000-0000-0000-${String(n).padStart(UUID_PAD_LENGTH, "0")}`; const mockNotes = "Alérgico a las nueces y frutos secos."; const mockOrder: Order = { zzz_id: mockId(10), zzz_reservation_id: mockId(2), - zzz_catalog_type_id: 1, + zzz_product_category_id: 1, zzz_global_status: "CONFIRMED", zzz_confirmed_venture_id: 1, zzz_notes: mockNotes, @@ -138,6 +140,7 @@ describe("OrderScreen (Tourist)", () => { zzz_is_paused: false, zzz_is_active: true, zzz_project_id: 1, + zzz_product_category_id: 1, }, }; diff --git a/apps/mobile/src/app/tourist/login.tsx b/apps/mobile/src/app/tourist/login.tsx index f2d6401..7b24851 100644 --- a/apps/mobile/src/app/tourist/login.tsx +++ b/apps/mobile/src/app/tourist/login.tsx @@ -1,15 +1,21 @@ -import { useState } from "react"; +import { useState, useTransition } from "react"; import { View, Text, ScrollView, KeyboardAvoidingView, Platform } from "react-native"; import { Image } from "expo-image"; import { useRouter } from "expo-router"; import Screen from "../../components/Screen"; import { Button } from "../../components/Button"; import { FormInput } from "../../components/FormInput"; +import { Icon } from "../../components/Icon"; +import LoadingView from "../../components/LoadingView"; import { useTranslations } from "../../hooks/useI18n"; import { useAuthStore } from "../../stores/auth.store"; -import { CreateUserInput, UserRole } from "@repo/shared"; +import { COLORS, CreateUserInput, UserRole } from "@repo/shared"; import jaguarHero from "../../../assets/jaguar-hero.png"; -import { logger } from "../../services/logger.service"; + +const IMAGE_TRANSITION_DURATION = 200; +const ICON_SIZE_ALERT = 20; + +const toNullable = (v: string | undefined): string | null => (v ? v : null); interface LoginFormData { alias: string; @@ -32,6 +38,8 @@ export default function LoginScreen() { lastName: "", }); const [errors, setErrors] = useState({}); + const [isPending, startTransition] = useTransition(); + const [submissionError, setSubmissionError] = useState(null); const validateForm = (): boolean => { const newErrors: FormErrors = {}; @@ -39,30 +47,32 @@ export default function LoginScreen() { newErrors.alias = t("login.alias_required"); } setErrors(newErrors); + setSubmissionError(null); return Object.keys(newErrors).length === 0; }; const handleSubmit = () => { - if (!validateForm()) { + if (!validateForm() || isPending) { return; } - const toNullable = (v: string | undefined) => (v ? v : null); + setSubmissionError(null); const userData: CreateUserInput = { alias: formData.alias.trim(), - firstName: toNullable(formData.firstName.trim()) || null, - lastName: toNullable(formData.lastName.trim()) || null, - phoneNumber: toNullable(formData.phoneNumber.trim()) || null, + firstName: toNullable(formData.firstName.trim()), + lastName: toNullable(formData.lastName.trim()), + phoneNumber: toNullable(formData.phoneNumber.trim()), role: UserRole.TOURIST, email: null, }; const register = useAuthStore.getState().register; - register(userData) - .then(() => { + startTransition(async () => { + try { + await register(userData); replace("/tourist"); - }) - .catch((error) => { - logger.error("Registration failed", error); - }); + } catch { + setSubmissionError(t("login.errors.registration_failed")); + } + }); }; const updateField = (field: keyof LoginFormData, value: string) => { @@ -70,10 +80,15 @@ export default function LoginScreen() { if (errors[field as keyof FormErrors]) { setErrors((prev) => ({ ...prev, [field]: undefined })); } + // Clear submission error when user starts typing again + if (submissionError) { + setSubmissionError(null); + } }; return ( + {isPending && } - + @@ -151,10 +166,22 @@ export default function LoginScreen() { + {submissionError && ( + + + {submissionError} + + )}