Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion apps/backend/src/db/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<typeof createDb>;
2 changes: 1 addition & 1 deletion apps/backend/src/db/schema/orders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
8 changes: 7 additions & 1 deletion apps/backend/src/db/schema/ventures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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,
});
3 changes: 2 additions & 1 deletion apps/backend/src/db/seed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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!");
Expand Down
75 changes: 74 additions & 1 deletion apps/backend/src/routes/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppEnv>();

Expand Down Expand Up @@ -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<typeof dbFactory.createDb>;

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<typeof dbFactory.createDb>);
resetDbCache();
});
});

afterAll(() => {
Expand Down
89 changes: 66 additions & 23 deletions apps/backend/src/routes/orders.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand All @@ -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[]) => {
Expand Down Expand Up @@ -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<string, unknown>),
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<string, unknown>),
zzz_id: `item-${i}`,
}));
return Promise.resolve(createdItems);
}
},
}),
}),
update: () => ({
set: () => ({
set: (_vals: unknown) => ({
where: () => ({
returning: () => {
const result = selectResults[0] ?? [mockOrder];
Expand Down Expand Up @@ -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 }],
};
Expand Down Expand Up @@ -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);
});

Expand Down
2 changes: 2 additions & 0 deletions apps/backend/src/routes/ventures.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down
Loading
Loading