Skip to content

Matb85/hms-agh-2025

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

25 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Miniprojekt

Mateusz Bis, Maciej Słowik

Link do repozytorium: https://github.com/Matb85/hms-agh-2025

1. Technologia

Baza danych: MongoDB 8

  • Elastyczny schemat danych w formacie JSON.
  • Skalowalność dzięki replikacji i shardingowi.
  • Zaawansowane możliwości agregacji i indeksowania.
  • Obsługa transakcji wielodokumentowych.

Język: TypeScript (kompilowany do JavaScript)

  • Statyczne typowanie wykrywające błędy na etapie pisania kodu.
  • Lepsza czytelność i dokumentacja dzięki typom.
  • Kompatybilny z JavaScript.
  • Wsparcie w edytorach (podpowiedzi, uzupełnianie).
  • Ułatwia rozwój dużych projektów.

Runtime JavaScript: Bun v1.2.15

  • Wysoka wydajność dzięki implementacji w Zig.
  • Zintegrowane narzędzia: bundler, transpiler, menedżer pakietów.
  • Kompatybilny z Node.js.
  • Natywna obsługa TypeScript.

Dokumentacja i testowanie API: Swagger UI

  • Automatyczna, interaktywna dokumentacja na podstawie OpenAPI.
  • Możliwość testowania API z poziomu przeglądarki.
  • Standaryzacja zgodna ze specyfikacją OpenAPI.
  • Prosta integracja z projektami.
  • Wsparcie dla wersjonowania i rozszerzeń.

Frameworki:

  • CRUD API: HonoJS

    • Lekki i szybki framework webowy.
    • Proste i czytelne API.
    • Wsparcie TypeScript.
    • Modularna budowa.
  • ODM: MongooseJS

    • Definiowanie schematów i modeli MongoDB.
    • Walidacja i middleware.
    • Ułatwia operacje CRUD.
    • Typy dla TypeScript.

Instalacja

bun install

Uruchomienie

docker compose up # uruchomienie MongoDB
bun openapi # generacja dokumentacji dla Swagger UI
bun dev # uruchomienie serwera

Przeprowadzenie testów

bun test

Seeding bazy danych

bun seed # Wypełnienie bazy danych losowymi danymi

2. Model Bazy Danych

Zaprojektowana przez nas baza danych będzie posiadała następujące kolekcje:

  • User – klienci (prosty model)
  • Hotel – hotel + zagnieżdżone pokoje
  • Reservation – osobna kolekcja, bo wymaga transakcji i współbieżności
  • RoomPrice – wersjonowanie cen (historia zmian)
  1. User
import { Schema, model } from "mongoose";

const userSchema = new Schema(
  {
    name: { type: String, required: true },
    email: { type: String, required: true, unique: true },
    phone: { type: String },
    createdAt: { type: Date, default: Date.now },
  },
  { timestamps: true }
);

export type User = typeof userSchema extends Schema<infer T> ? T : never;
export const User = model("User", userSchema);
  • Klienci rzadko się zmieniają więc trzymamy dane osobowe płasko.
  1. Hotel z pokojami jako poddokumenty
import { Schema, model, Types } from "mongoose";

const roomSchema = new Schema(
  {
    roomNumber: { type: String, required: true },
    type: { type: String, enum: ["single", "double"], required: true },
    standard: { type: String, enum: ["basic", "premium", "exclusive"], required: true },
    isAvailable: { type: Boolean, default: true },
    currentPrice: { type: Number, required: true },
  },
  { _id: false }
);

const hotelSchema = new Schema(
  {
    name: { type: String, required: true },
    city: { type: String, required: true },
    address: { type: String, required: true },
    rooms: [roomSchema],
    createdAt: { type: Date, default: Date.now },
  },
  { timestamps: true }
);

export type Hotel = typeof hotelSchema extends Schema<infer T> ? T : never;
export const Hotel = model("Hotel", hotelSchema);
  • Ponieważ MongoDB preferuje agregację danych, pokoje logicznie przynależą do hotelu.
  • currentPrice – redundancja celowa (dla szybkiego dostępu i uproszczenia wyświetlania).
  • isAvailable – może być obliczany dynamicznie, ale do demo warto go aktualizować przy rezerwacjach.
  1. RoomPrice – historia zmian cen
import { Schema, model, Types } from "mongoose";

const roomPriceSchema = new Schema(
  {
    hotelId: { type: Types.ObjectId, ref: "Hotel", required: true },
    roomNumber: { type: String, required: true },
    fromDate: { type: Date, required: true },
    toDate: { type: Date, required: true },
    price: { type: Number, required: true },
  },
  { timestamps: true }
);

export type RoomPrice = typeof roomPriceSchema extends Schema<infer T> ? T : never;
export const RoomPrice = model("RoomPrice", roomPriceSchema);
  • Oddzielenie historii cen umożliwia analizę, porównania sezonowe, raporty.
  • Nie ładujemy ich w hotel.rooms – bo historia może być długa.
  1. Reservation – osobna kolekcja, do obsługi transakcji
import { Schema, model, Types } from "mongoose";

const reservationSchema = new Schema(
  {
    userId: { type: Types.ObjectId, ref: "User", required: true },
    hotelId: { type: Types.ObjectId, ref: "Hotel", required: true },
    roomNumber: { type: String, required: true },
    startDate: { type: Date, required: true },
    endDate: { type: Date, required: true },
    status: {
      type: String,
      enum: ["pending", "confirmed", "cancelled"],
      default: "pending",
    },
    createdAt: { type: Date, default: Date.now },
  },
  { timestamps: true }
);

export type Reservation = typeof reservationSchema extends Schema<infer T> ? T : never;
export const Reservation = model("Reservation", reservationSchema);
  • Możemy wprowadzić transakcję: np. przy rezerwacji atomowo sprawdzamy dostępność i zapisujemy rezerwację.
  • Umożliwia łatwą obsługę wielu stanów (pending - rezerwacja oczekująca na potwierdzenie, confirmed - rezerwacja potwierdzona, cancelled - rezerwacja odwołana).
  • Osobna kolekcja ułatwia kontrolę współbieżności.

3. Dokumentacja endpointów API

Dla wszystkich możliwych odpowiedzi napisaliśmy testy znajdujące się w plikach hotels.test.ts,prices.test.ts i reservations.test.ts. Ich opis znajduje się w następnej sekcji. Dodatkowe informacje:

  • Każdy test działa na czystej bazie danych (beforeEach czyści kolekcję).
  • Testy korzystają z natywnej metody app.fetch do symulowania rzeczywistych żądań HTTP.
  • Po zakończeniu testów baza danych jest usuwana, co zapewnia powtarzalność testów (afterAll).

Hotele i pokoje

GET /hotels

Opis:
Zwraca listę wszystkich hoteli.

parametry wyszukiwania

  • id: string
  • name: string

Odpowiedź:

  • 200: Tablica obiektów hotelu.

GET /hotels/:id

Opis:
Zwraca szczegóły hotelu o podanym ID.

Odpowiedź:

  • 200: Obiekt hotelu.
  • 404: Nie znaleziono hotelu.

POST /hotels

Opis:
Tworzy nowy hotel.

Body:

const body = {
  name: "string",
  city: "string",
  address: "string",
  rooms: [
    {
      roomNumber: "string",
      type: "single|double",
      standard: "basic|premium|exclusive",
      isAvailable: true,
      currentPrice: 1,
    },
  ],
};

Odpowiedź:

  • 201: Utworzony hotel.
  • 400: Błędne dane.

PUT /hotels/:id

Opis:
Aktualizuje dane hotelu o podanym ID.

Body:
Dowolne pola hotelu do aktualizacji.

Odpowiedź:

  • 200: Zaktualizowany hotel.

DELETE /hotels/:id

Opis:
Usuwa hotel o podanym ID.

Odpowiedź:

  • 200: Tekst "Deleted".

GET /hotels/:id/rooms

Opis:
Zwraca pokoje hotelu.

Parametry wyszukiwania:

  • roomNumber: string
  • type: "single" | "double"
  • standard: "basic" | "premium" | "exclusive"
  • isAvailable: "true" | "false"
  • currentPrice: liczba

Odpowiedź:

  • 200: Zaktualizowany hotel z nowym pokojem.
  • 404: Nie znaleziono hotelu.

POST /hotels/:id/rooms

Opis:
Dodaje pokój do hotelu.

Body:
Dane pokoju.

Odpowiedź:

  • 200: Zaktualizowany hotel z nowym pokojem.
  • 404: Nie znaleziono hotelu.

PUT /hotels/:id/rooms/:roomNumber

Opis:
Aktualizuje dane pokoju w hotelu.

Body:
Dane do aktualizacji pokoju.

Odpowiedź:

  • 200: Zaktualizowany pokój.
  • 404: Nie znaleziono hotelu lub pokoju.

Testy - hotels.test.ts

Kod testu:

import { describe, expect, it, beforeAll, afterAll, beforeEach } from "bun:test";
import { connectDB } from "../db";
import { Hotel, type RoomData } from "../models/hotel.model";
import app from "../index";
import mongoose from "mongoose";
import type { ApiErrorI } from "../utils";

const baseUrl = "http://localhost/hotels";

beforeAll(async () => {
  await connectDB();
});

afterAll(async () => {
  await mongoose.connection.dropDatabase();
  await mongoose.connection.close();
});

beforeEach(async () => {
  await Hotel.deleteMany({});
});

describe("Hotel API integration - hotels", () => {
  it("should create a hotel", async () => {
    const res = await app.fetch(
      new Request(`${baseUrl}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          name: "Test Hotel",
          city: "Test City",
          address: "123 Test St",
          rooms: [],
        }),
      })
    );
    expect(res.status).toBe(201);
    const hotel = (await res.json()) as Hotel;
    expect(hotel.name).toBe("Test Hotel");
    const inDb = await Hotel.findById(hotel._id);
    expect(inDb).not.toBeNull();
    expect(inDb?.name).toBe("Test Hotel");
  });

  it("should list hotels", async () => {
    await Hotel.create({ name: "A", city: "B", address: "C", rooms: [] });
    const res = await app.fetch(new Request(`${baseUrl}`));
    expect(res.status).toBe(200);
    const hotels = (await res.json()) as Hotel[];
    expect(Array.isArray(hotels)).toBe(true);
    expect(hotels.length).toBe(1);
  });

  it("should filter hotels by name", async () => {
    await Hotel.create({ name: "Alpha Hotel", city: "X", address: "Y", rooms: [] });
    await Hotel.create({ name: "Beta Hotel", city: "X", address: "Y", rooms: [] });

    const res = await app.fetch(new Request(`${baseUrl}?name=Alpha`));
    expect(res.status).toBe(200);
    const hotels = (await res.json()) as Hotel[];
    expect(Array.isArray(hotels)).toBe(true);
    expect(hotels.length).toBe(1);
    expect(hotels[0]!.name).toBe("Alpha Hotel");
  });

  it("should filter hotels by id", async () => {
    const hotel = await Hotel.create({ name: "Gamma Hotel", city: "X", address: "Y", rooms: [] });
    await Hotel.create({ name: "Delta Hotel", city: "X", address: "Y", rooms: [] });

    const res = await app.fetch(new Request(`${baseUrl}?id=${hotel._id}`));
    expect(res.status).toBe(200);
    const hotels = (await res.json()) as Hotel[];
    expect(Array.isArray(hotels)).toBe(true);
    expect(hotels.length).toBe(1);
    expect(String(hotels[0]!._id)).toBe(String(hotel!._id));
    expect(hotels[0]!.name).toBe("Gamma Hotel");
  });

  it("should get a hotel by id", async () => {
    const hotel = await Hotel.create({ name: "A", city: "B", address: "C", rooms: [] });
    const res = await app.fetch(new Request(`${baseUrl}/${hotel._id}`));
    expect(res.status).toBe(200);
    const data = (await res.json()) as Hotel;
    expect(data.name).toBe("A");
  });

  it("should return 404 for non-existent hotel", async () => {
    const res = await app.fetch(new Request(`${baseUrl}/nonexistentid`));
    expect(res.status).toBe(404);
    const data = (await res.json()) as ApiErrorI;
    expect(data.message).toBe("Hotel not found");
  });

  describe("Hotel API integration - rooms", () => {
    it("should update a hotel", async () => {
      const hotel = await Hotel.create({ name: "A", city: "B", address: "C", rooms: [] });
      const res = await app.fetch(
        new Request(`${baseUrl}/${hotel._id}`, {
          method: "PUT",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ name: "Updated" }),
        })
      );
      expect(res.status).toBe(200);
      const updated = (await res.json()) as Hotel;
      expect(updated.name).toBe("Updated");
      const inDb = await Hotel.findById(hotel._id);
      expect(inDb?.name).toBe("Updated");
    });

    it("should delete a hotel", async () => {
      const hotel = await Hotel.create({ name: "A", city: "B", address: "C", rooms: [] });
      const res = await app.fetch(new Request(`${baseUrl}/${hotel._id}`, { method: "DELETE" }));
      expect(res.status).toBe(200);
      const inDb = await Hotel.findById(hotel._id);
      expect(inDb).toBeNull();
    });
  });

  it("should add a room to a hotel", async () => {
    const hotel = await Hotel.create({ name: "A", city: "B", address: "C", rooms: [] });
    const room = {
      roomNumber: "101",
      type: "single",
      standard: "basic",
      currentPrice: 100,
    };
    const res = await app.fetch(
      new Request(`${baseUrl}/${hotel._id}/rooms`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(room),
      })
    );
    expect(res.status).toBe(200);
    const updated = (await res.json()) as Hotel;
    expect(updated.rooms.length).toBe(1);
    expect(updated.rooms[0]!.roomNumber).toBe("101");
    const inDb = await Hotel.findById(hotel._id);
    expect(inDb?.rooms?.[0]?.roomNumber).toBe("101");
  });

  it("should return 404 when adding a room to a non-existent hotel", async () => {
    const res = await app.fetch(
      new Request(`${baseUrl}/nonexistentid/rooms`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          roomNumber: "101",
          type: "single",
          standard: "basic",
          currentPrice: 100,
        }),
      })
    );
    expect(res.status).toBe(404);
    const data = (await res.json()) as ApiErrorI;
    expect(data.message).toBe("Hotel not found");
  });

  it("should update a room in a hotel", async () => {
    const hotel = await Hotel.create({
      name: "A",
      city: "B",
      address: "C",
      rooms: [
        {
          roomNumber: "101",
          type: "single",
          standard: "basic",
          currentPrice: 100,
        },
      ],
    });
    const res = await app.fetch(
      new Request(`${baseUrl}/${hotel._id}/rooms/101`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ currentPrice: 200 }),
      })
    );
    expect(res.status).toBe(200);
    const updatedRoom = (await res.json()) as RoomData;
    expect(updatedRoom.currentPrice).toBe(200);
    const inDb = await Hotel.findById(hotel._id);
    expect(inDb?.rooms?.[0]?.currentPrice).toBe(200);
  });

  it("should filter rooms by roomNumber", async () => {
    const hotel = await Hotel.create({
      name: "FilterTest",
      city: "X",
      address: "Y",
      rooms: [
        { roomNumber: "101", type: "single", standard: "basic", isAvailable: true, currentPrice: 100 },
        { roomNumber: "102", type: "double", standard: "premium", isAvailable: false, currentPrice: 200 },
      ],
    });

    const res = await app.fetch(new Request(`${baseUrl}/${hotel._id}/rooms?roomNumber=101`));
    expect(res.status).toBe(200);
    const rooms = (await res.json()) as RoomData[];
    expect(Array.isArray(rooms)).toBe(true);
    expect(rooms.length).toBe(1);
    expect(rooms[0]!.roomNumber).toBe("101");
  });

  it("should filter rooms by type", async () => {
    const hotel = await Hotel.create({
      name: "FilterTest2",
      city: "X",
      address: "Y",
      rooms: [
        { roomNumber: "201", type: "single", standard: "basic", isAvailable: true, currentPrice: 100 },
        { roomNumber: "202", type: "double", standard: "premium", isAvailable: false, currentPrice: 200 },
      ],
    });

    const res = await app.fetch(new Request(`${baseUrl}/${hotel._id}/rooms?type=double`));
    expect(res.status).toBe(200);
    const rooms = (await res.json()) as RoomData[];
    expect(Array.isArray(rooms)).toBe(true);
    expect(rooms.length).toBe(1);
    expect(rooms[0]!.type).toBe("double");
  });

  it("should filter rooms by standard", async () => {
    const hotel = await Hotel.create({
      name: "FilterTest3",
      city: "X",
      address: "Y",
      rooms: [
        { roomNumber: "301", type: "single", standard: "basic", isAvailable: true, currentPrice: 100 },
        { roomNumber: "302", type: "double", standard: "exclusive", isAvailable: false, currentPrice: 200 },
      ],
    });

    const res = await app.fetch(new Request(`${baseUrl}/${hotel._id}/rooms?standard=exclusive`));
    expect(res.status).toBe(200);
    const rooms = (await res.json()) as RoomData[];
    expect(Array.isArray(rooms)).toBe(true);
    expect(rooms.length).toBe(1);
    expect(rooms[0]!.standard).toBe("exclusive");
  });

  it("should filter rooms by isAvailable", async () => {
    const hotel = await Hotel.create({
      name: "FilterTest4",
      city: "X",
      address: "Y",
      rooms: [
        { roomNumber: "401", type: "single", standard: "basic", isAvailable: true, currentPrice: 100 },
        { roomNumber: "402", type: "double", standard: "premium", isAvailable: false, currentPrice: 200 },
      ],
    });

    const res = await app.fetch(new Request(`${baseUrl}/${hotel._id}/rooms?isAvailable=false`));
    expect(res.status).toBe(200);
    const rooms = (await res.json()) as RoomData[];
    expect(Array.isArray(rooms)).toBe(true);
    expect(rooms.length).toBe(1);
    expect(rooms[0]!.isAvailable).toBe(false);
  });

  it("should filter rooms by currentPrice", async () => {
    const hotel = await Hotel.create({
      name: "FilterTest5",
      city: "X",
      address: "Y",
      rooms: [
        { roomNumber: "501", type: "single", standard: "basic", isAvailable: true, currentPrice: 100 },
        { roomNumber: "502", type: "double", standard: "premium", isAvailable: false, currentPrice: 200 },
      ],
    });

    const res = await app.fetch(new Request(`${baseUrl}/${hotel._id}/rooms?currentPrice=200`));
    expect(res.status).toBe(200);
    const rooms = (await res.json()) as RoomData[];
    expect(Array.isArray(rooms)).toBe(true);
    expect(rooms.length).toBe(1);
    expect(rooms[0]!.currentPrice).toBe(200);
  });
});

Opis:

Testy integracyjne dla API hoteli i pokoi sprawdzają następujące funkcjonalności:

Testy podstawowych operacji na hotelach

  • Tworzenie hotelu z walidacją statusu 201 i poprawności danych
  • Pobieranie listy hoteli z weryfikacją odpowiedzi 200
  • Filtrowanie hoteli po nazwie i ID
  • Pobieranie szczegółów hotelu z obsługą błędu 404
  • Aktualizacja danych hotelu
  • Usuwanie hotelu z weryfikacją usunięcia

Testy operacji na pokojach

  • Dodawanie pokoju do hotelu z walidacją statusu 200
  • Obsługa próby dodania pokoju do nieistniejącego hotelu (404)
  • Aktualizacja danych pokoju
  • Filtrowanie pokoi według różnych kryteriów:
    • Numer pokoju
    • Typ pokoju (single/double)
    • Standard (basic/premium/exclusive)
    • Dostępność
    • Cena

Ceny pokoi

GET /prices

Opis:
Zwraca listę wszystkich cen pokoi.

Odpowiedź:

  • 200: Tablica obiektów RoomPrice.

GET /prices/:id

Opis:
Zwraca szczegóły ceny pokoju o podanym ID.

Odpowiedź:

  • 200: Obiekt RoomPrice.
  • 404: Nie znaleziono ceny.

POST /prices

Opis:
Tworzy nową cenę pokoju.

Body:

const body = {
  hotelId: "absd",
  roomNumber: "101",
  fromDate: new Date("2025-01-01"),
  toDate: new Date("2025-01-10"),
  price: 123,
};

Odpowiedź:

  • 201: Utworzona cena pokoju.

PUT /prices/:id

Opis:
Aktualizuje cenę pokoju o podanym ID.

Body:
Dane do aktualizacji.

Odpowiedź:

  • 200: Zaktualizowana cena pokoju.

DELETE /prices/:id

Opis:
Usuwa cenę pokoju o podanym ID.

Odpowiedź:

  • 200: Tekst "Deleted".

Testy - prices.test.ts

Kod testu:

import { describe, expect, it, beforeAll, afterAll, beforeEach } from "bun:test";
import { connectDB } from "../db";
import { RoomPrice } from "../models/roomprice.model";
import { Hotel } from "../models/hotel.model";
import app from "../index";
import mongoose from "mongoose";
import type { ApiErrorI } from "../utils";

const baseUrl = "http://localhost/prices";

let hotelId: string;

beforeAll(async () => {
  await connectDB();

  const hotel = await Hotel.create({
    name: "Hotel for Price",
    city: "City",
    address: "Addr",
    rooms: [{ roomNumber: "101", type: "single", standard: "basic", isAvailable: true, currentPrice: 100 }],
  });
  hotelId = hotel._id.toString();
});

afterAll(async () => {
  await mongoose.connection.dropDatabase();
  await mongoose.connection.close();
});

beforeEach(async () => {
  await RoomPrice.deleteMany({});
});

describe("RoomPrice API integration", () => {
  it("should create a room price", async () => {
    const body = {
      hotelId,
      roomNumber: "101",
      fromDate: new Date("2025-01-01"),
      toDate: new Date("2025-01-10"),
      price: 123,
    };
    const res = await app.fetch(
      new Request(`${baseUrl}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      })
    );
    expect(res.status).toBe(201);
    const created = (await res.json()) as RoomPrice;
    expect(created.hotelId as string).toBe(hotelId);
    expect(created.roomNumber).toBe("101");
    const inDb = await RoomPrice.findById(created._id);
    expect(inDb).not.toBeNull();
    expect(inDb?.price).toBe(123);
  });

  it("should not create a room price for non-existent room or hotel", async () => {
    const body = {
      hotelId: "nonexistent",
      roomNumber: "999",
      fromDate: new Date("2025-01-01"),
      toDate: new Date("2025-01-10"),
      price: 123,
    };
    const res = await app.fetch(
      new Request(`${baseUrl}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      })
    );
    expect(res.status).toBe(400);
    const error = (await res.json()) as ApiErrorI;
    expect(error.message).toBe("Invalid hotel ID");
  });

  it("should list all room prices", async () => {
    await RoomPrice.create({
      hotelId,
      roomNumber: "101",
      fromDate: new Date("2025-01-01"),
      toDate: new Date("2025-01-10"),
      price: 123,
    });
    const res = await app.fetch(new Request(`${baseUrl}`));
    expect(res.status).toBe(200);
    const prices = (await res.json()) as RoomPrice[];
    expect(Array.isArray(prices)).toBe(true);
    expect(prices.length).toBe(1);
    expect(prices[0]!.roomNumber).toBe("101");
  });

  it("should get a room price by id", async () => {
    const price = await RoomPrice.create({
      hotelId,
      roomNumber: "102",
      fromDate: new Date("2025-02-01"),
      toDate: new Date("2025-02-10"),
      price: 200,
    });
    const res = await app.fetch(new Request(`${baseUrl}/${price._id}`));
    expect(res.status).toBe(200);
    const data = (await res.json()) as RoomPrice;
    expect(data.roomNumber).toBe("102");
    expect(data.price).toBe(200);
  });

  it("should update a room price", async () => {
    const price = await RoomPrice.create({
      hotelId,
      roomNumber: "103",
      fromDate: new Date("2025-03-01"),
      toDate: new Date("2025-03-10"),
      price: 300,
    });
    const res = await app.fetch(
      new Request(`${baseUrl}/${price._id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ price: 350 }),
      })
    );
    expect(res.status).toBe(200);
    const updated = (await res.json()) as RoomPrice;
    expect(updated.price).toBe(350);
    const inDb = await RoomPrice.findById(price._id);
    expect(inDb?.price).toBe(350);
  });

  it("should delete a room price", async () => {
    const price = await RoomPrice.create({
      hotelId,
      roomNumber: "104",
      fromDate: new Date("2025-04-01"),
      toDate: new Date("2025-04-10"),
      price: 400,
    });
    const res = await app.fetch(new Request(`${baseUrl}/${price._id}`, { method: "DELETE" }));
    expect(res.status).toBe(200);
    const inDb = await RoomPrice.findById(price._id);
    expect(inDb).toBeNull();
  });
});

Opis:

Testy integracyjne dla API cen pokoi sprawdzają następujące funkcjonalności:

Testy operacji CRUD na cenach pokoi

  • Tworzenie nowej ceny pokoju z walidacją statusu 201 i poprawności danych
  • Obsługa próby utworzenia ceny dla nieistniejącego hotelu (400)
  • Pobieranie listy wszystkich cen pokoi z weryfikacją odpowiedzi 200
  • Pobieranie szczegółów konkretnej ceny pokoju
  • Aktualizacja istniejącej ceny pokoju
  • Usuwanie ceny pokoju z weryfikacją usunięcia

Rezerwacje

GET /reservations

Opis:
Zwraca listę wszystkich rezerwacji.

Parametry wyszukiwania:

  • hotelId: string
  • userId: string
  • status: "pending" | "confirmed" | "cancelled"
  • fromDate: data
  • toDate: data

Odpowiedź:

  • 200: Tablica obiektów Reservation.

POST /reservations

Opis:
Tworzy nową rezerwację pokoju.
Zapobiega rezerwacjom nakładającym się na te same daty dla tego samego pokoju.

Body:

const body = {
  hotelId: "string",
  roomNumber: "string",
  userId: "string",
  fromDate: new Date("YYYY-MM-DD"),
  toDate: new Date("YYYY-MM-DD"),
};

Odpowiedź:

  • 201: Utworzona rezerwacja.
  • 400: Użytkownik nie istnieje lub błędne dane.
  • 409: Pokój już zarezerwowany w tym zakresie dat.

DELETE /reservations/:id

Opis:
Usuwa rezerwację o podanym ID.

Odpowiedź:

  • 200: { message: "Reservation canceled" }
  • 404: Nie znaleziono rezerwacji.

Testy - reservations.test.ts

Kod testu:

import { describe, expect, it, beforeAll, afterAll, beforeEach } from "bun:test";
import { connectDB } from "../db";
import { Reservation } from "../models/reservation.model";
import { Hotel } from "../models/hotel.model";
import { User } from "../models/user.model";
import { dateToYYYYMMDD, type ApiErrorI } from "../utils";
import app from "../index";
import mongoose from "mongoose";

const baseUrl = "http://localhost/reservations";

const DAY_IN_MS = 24 * 60 * 60 * 1000; // 1 day in milliseconds

let hotelId: string;
const roomNumber = "201";
let userId: string;
let user2Id: string;
const nonExistentUserId = "60c72b2f9b1d8c001c8e4e1a"; // Example non-existent user ID

beforeAll(async () => {
  await connectDB();
  const hotel = await Hotel.create({
    name: "Hotel for Reservations",
    city: "City",
    address: "Addr",
    rooms: [{ roomNumber, type: "single", standard: "basic", currentPrice: 100 }],
  });
  hotelId = hotel._id.toString();

  const user = await User.create({
    name: "Test User",
    email: "test@gmail.com",
  });
  userId = user._id.toString();
  const user2 = await User.create({
    name: "Test User 2",
    email: "test2@gmail.com",
  });
  user2Id = user2._id.toString();
});

afterAll(async () => {
  await mongoose.connection.dropDatabase();
  await mongoose.connection.close();
});

beforeEach(async () => {
  await Reservation.deleteMany({});
});

describe("Reservation API integration", () => {
  it("should create a reservation", async () => {
    const body = {
      hotelId,
      roomNumber,
      userId,
      fromDate: dateToYYYYMMDD(new Date(Date.now() + DAY_IN_MS)),
      toDate: dateToYYYYMMDD(new Date(Date.now() + 3 * DAY_IN_MS)),
    };
    const res = await app.fetch(
      new Request(`${baseUrl}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify(body),
      })
    );
    expect(res.status).toBe(201);
    const created = (await res.json()) as Reservation;
    expect(created.hotelId as string).toBe(hotelId);
    expect(created.roomNumber).toBe(roomNumber);
    expect(created.userId as string).toBe(userId);
    const inDb = await Reservation.findById(created._id);
    expect(inDb).not.toBeNull();
    expect(inDb?.roomNumber).toBe(roomNumber);
  });

  it("should not create a reservation for non-existent user", async () => {
    const res = await app.fetch(
      new Request(`${baseUrl}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          hotelId,
          roomNumber,
          userId: nonExistentUserId,
          fromDate: dateToYYYYMMDD(new Date(Date.now() + DAY_IN_MS)),
          toDate: dateToYYYYMMDD(new Date(Date.now() + 3 * DAY_IN_MS)),
        }),
      })
    );
    expect(res.status).toBe(400);
    const data = (await res.json()) as ApiErrorI;
    expect(data.message).toMatch(/does not exist/i);
  });

  it("should allow only one reservation for the same room and overlapping dates under concurrent requests", async () => {
    const fromDate = dateToYYYYMMDD(new Date(Date.now() + DAY_IN_MS));
    const toDate = dateToYYYYMMDD(new Date(Date.now() + 3 * DAY_IN_MS));

    const body = {
      hotelId,
      roomNumber,
      userId,
      fromDate,
      toDate,
    };

    // Symuluj 5 równoległych żądań dla tego samego pokoju i zakresu dat
    const requests = Array.from({ length: 5 }).map(() =>
      app.fetch(
        new Request(baseUrl, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(body),
        })
      )
    );

    const results = await Promise.all(requests);

    // Tylko jeden powinien być sukcesem (201), reszta powinna być 409
    const statuses = results.map(r => r.status);
    const successCount = statuses.filter(s => s === 201).length;
    const conflictCount = statuses.filter(s => s === 409).length;

    expect(successCount).toBe(1);
    expect(conflictCount).toBe(4);

    // Tylko jedna rezerwacja powinna istnieć w bazie danych
    const reservationsInDb = await Reservation.find({
      hotelId,
      roomNumber,
      fromDate: { $lte: new Date(fromDate) },
      toDate: { $gte: new Date(toDate) },
    });
    expect(reservationsInDb.length).toBe(1);
  });

  it("should not allow overlapping reservations", async () => {
    await Reservation.create({
      hotelId,
      roomNumber,
      userId,
      fromDate: dateToYYYYMMDD(new Date(Date.now() + 1 * DAY_IN_MS)),
      toDate: dateToYYYYMMDD(new Date(Date.now() + 3 * DAY_IN_MS)),
    });
    const res = await app.fetch(
      new Request(`${baseUrl}`, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          hotelId,
          roomNumber,
          userId: user2Id,
          fromDate: dateToYYYYMMDD(new Date(Date.now() + 2 * DAY_IN_MS)),
          toDate: dateToYYYYMMDD(new Date(Date.now() + 4 * DAY_IN_MS)),
        }),
      })
    );
    expect(res.status).toBe(409);
    const data = (await res.json()) as ApiErrorI;
    expect(data.message).toMatch(/already reserved/i);
  });

  it("should list all reservations", async () => {
    await Reservation.create({
      hotelId,
      roomNumber,
      userId,
      fromDate: "2025-06-10",
      toDate: "2025-06-15",
    });
    const res = await app.fetch(new Request(`${baseUrl}`));
    expect(res.status).toBe(200);
    const list = (await res.json()) as Reservation[];
    expect(Array.isArray(list)).toBe(true);
    expect(list.length).toBe(1);
    expect(list[0]!.roomNumber).toBe(roomNumber);
  });

  it("should delete a reservation", async () => {
    const reservation = await Reservation.create({
      hotelId,
      roomNumber,
      userId,
      fromDate: "2025-06-10",
      toDate: "2025-06-15",
    });
    const res = await app.fetch(new Request(`${baseUrl}/${reservation._id}`, { method: "DELETE" }));
    expect(res.status).toBe(200);
    const data = (await res.json()) as ApiErrorI;
    expect(data.message).toMatch(/cancelled/i);
    const inDb = await Reservation.findById(reservation._id);
    expect(inDb).toBeNull();
  });

  it("should filter reservations by hotelId", async () => {
    const otherHotel = await Hotel.create({
      name: "Other Hotel",
      city: "City",
      address: "Addr",
      rooms: [{ roomNumber: "301", type: "single", standard: "basic", currentPrice: 100 }],
    });

    await Reservation.create({
      hotelId,
      roomNumber,
      userId,
      fromDate: "2025-06-10",
      toDate: "2025-06-15",
    });
    await Reservation.create({
      hotelId: otherHotel._id,
      roomNumber: "301",
      userId,
      fromDate: "2025-07-01",
      toDate: "2025-07-05",
    });

    const res = await app.fetch(new Request(`${baseUrl}?hotelId=${hotelId}`));
    expect(res.status).toBe(200);
    const list = (await res.json()) as Reservation[];
    expect(Array.isArray(list)).toBe(true);
    expect(list.length).toBe(1);
    expect(String(list[0]!.hotelId)).toBe(hotelId);
  });

  it("should filter reservations by userId", async () => {
    await Reservation.create({
      hotelId,
      roomNumber,
      userId,
      fromDate: "2025-06-10",
      toDate: "2025-06-15",
    });
    await Reservation.create({
      hotelId,
      roomNumber: "202",
      userId: user2Id,
      fromDate: "2025-07-01",
      toDate: "2025-07-05",
    });

    const res = await app.fetch(new Request(`${baseUrl}?userId=${user2Id}`));
    expect(res.status).toBe(200);
    const list = (await res.json()) as Reservation[];
    expect(Array.isArray(list)).toBe(true);
    expect(list.length).toBe(1);
    expect(String(list[0]!.userId)).toBe(user2Id);
  });

  it("should filter reservations by fromDate and toDate (overlapping)", async () => {
    await Reservation.create({
      hotelId,
      roomNumber,
      userId,
      fromDate: "2025-06-10",
      toDate: "2025-06-15",
    });
    await Reservation.create({
      hotelId,
      roomNumber: "202",
      userId,
      fromDate: "2025-07-01",
      toDate: "2025-07-05",
    });

    // Should match only the first reservation
    const res = await app.fetch(new Request(`${baseUrl}?fromDate=2025-06-12&toDate=2025-06-13`));
    expect(res.status).toBe(200);
    const list = (await res.json()) as Reservation[];
    expect(Array.isArray(list)).toBe(true);
    expect(list.length).toBe(1);
    expect(String(list[0]!.fromDate)).toBe("2025-06-10T00:00:00.000Z");
  });

  it("should filter reservations by status", async () => {
    // Assuming you have a status field in Reservation
    await Reservation.create({
      hotelId,
      roomNumber,
      userId,
      fromDate: "2025-06-10",
      toDate: "2025-06-15",
      status: "confirmed",
    });
    await Reservation.create({
      hotelId,
      roomNumber: "202",
      userId,
      fromDate: "2025-07-01",
      toDate: "2025-07-05",
      status: "cancelled",
    });

    const res = await app.fetch(new Request(`${baseUrl}?status=confirmed`));
    expect(res.status).toBe(200);
    const list = (await res.json()) as Reservation[];
    expect(Array.isArray(list)).toBe(true);
    expect(list.length).toBe(1);
    expect(list[0]!.status).toBe("confirmed");
  });
});

Opis:

Testy integracyjne dla API rezerwacji sprawdzają następujące funkcjonalności:

Operacje CRUD na rezerwacjach

  • Tworzenie nowej rezerwacji z walidacją statusu 201 i poprawności danych
  • Obsługa próby utworzenia rezerwacji dla nieistniejącego użytkownika (400)
  • Pobieranie listy wszystkich rezerwacji z weryfikacją odpowiedzi 200
  • Usuwanie rezerwacji z potwierdzeniem usunięcia

Zarządzanie współbieżnością

  • Zapobieganie równoległym rezerwacjom tego samego pokoju
  • Sprawdzanie nakładania się dat rezerwacji (409)
  • Weryfikacja unikalności rezerwacji dla danego pokoju

Filtrowanie rezerwacji

  • Filtrowanie po ID hotelu
  • Filtrowanie po ID użytkownika
  • Filtrowanie po zakresie dat (nakładające się rezerwacje)
  • Filtrowanie po statusie rezerwacji

Obsługa błędów

  • Walidacja danych wejściowych
  • Obsługa nieistniejących użytkowników
  • Obsługa nakładających się rezerwacji
  • Weryfikacja poprawności dat

Użytkownicy

GET /users

Opis:
Zwraca listę wszystkich użytkowników.

Odpowiedź:

  • 200: Tablica obiektów użytkownika.

GET /users/:id

Opis:
Zwraca szczegóły użytkownika o podanym ID.

Odpowiedź:

  • 200: Obiekt użytkownika.
  • 404: Nie znaleziono użytkownika.

POST /users

Opis:
Tworzy nowego użytkownika.

Body:

const body = {
  name: "string",
  email: "string",
  phone: "string",
};

Odpowiedź:

  • 201: Utworzony użytkownik.
  • 400: Błędne dane.

PUT /users/:id

Opis:
Aktualizuje dane użytkownika o podanym ID.

Body:
Dowolne pola użytkownika do aktualizacji.

Odpowiedź:

  • 200: Zaktualizowany użytkownik.

DELETE /users/:id

Opis:
Usuwa użytkownika o podanym ID.

Odpowiedź:

  • 200: Tekst "Deleted".

Testy - users.test.ts

Kod testu:

import { describe, expect, it, beforeAll, afterAll, beforeEach } from "bun:test";
import { connectDB } from "../db";
import { User } from "../models/user.model";
import app from "../index";
import mongoose from "mongoose";

const baseUrl = "http://localhost/users";

beforeAll(async () => {
  await connectDB();
});

afterAll(async () => {
  await mongoose.connection.dropDatabase();
  await mongoose.connection.close();
});

beforeEach(async () => {
  await User.deleteMany({});
});

describe("User API integration", () => {
  it("should create a user", async () => {
    const res = await app.fetch(
      new Request(baseUrl, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({
          name: "Jan Kowalski",
          email: "jan@kowalski.pl",
        }),
      })
    );
    expect(res.status).toBe(201);
    const user = (await res.json()) as User;
    expect(user.name).toBe("Jan Kowalski");
    expect(user.email).toBe("jan@kowalski.pl");
    const inDb = await User.findById(user._id);
    expect(inDb).not.toBeNull();
    expect(inDb?.name).toBe("Jan Kowalski");
  });

  it("should list users", async () => {
    await User.create({ name: "A", email: "a@a.pl" });
    const res = await app.fetch(new Request(baseUrl));
    expect(res.status).toBe(200);
    const users = (await res.json()) as User[];
    expect(Array.isArray(users)).toBe(true);
    expect(users.length).toBe(1);
    expect(users[0]!.name).toBe("A");
  });

  it("should get a user by id", async () => {
    const user = await User.create({ name: "B", email: "b@b.pl" });
    const res = await app.fetch(new Request(`${baseUrl}/${user._id}`));
    expect(res.status).toBe(200);
    const data = (await res.json()) as User;
    expect(data.name).toBe("B");
    expect(data.email).toBe("b@b.pl");
  });

  it("should return 404 for non-existent user", async () => {
    const res = await app.fetch(new Request(`${baseUrl}/60c72b2f9b1d8c001c8e4e1a`));
    expect(res.status).toBe(404);
  });

  it("should update a user", async () => {
    const user = await User.create({ name: "C", email: "c@c.pl" });
    const res = await app.fetch(
      new Request(`${baseUrl}/${user._id}`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name: "Cezary" }),
      })
    );
    expect(res.status).toBe(200);
    const updated = (await res.json()) as User;
    expect(updated.name).toBe("Cezary");
    const inDb = await User.findById(user._id);
    expect(inDb?.name).toBe("Cezary");
  });

  it("should delete a user", async () => {
    const user = await User.create({ name: "D", email: "d@d.pl" });
    const res = await app.fetch(new Request(`${baseUrl}/${user._id}`, { method: "DELETE" }));
    expect(res.status).toBe(200);
    const inDb = await User.findById(user._id);
    expect(inDb).toBeNull();
  });
});

Opis:

Testy integracyjne dla API użytkowników sprawdzają następujące funkcjonalności:

Operacje CRUD na użytkownikach

  • Tworzenie nowego użytkownika z walidacją statusu 201 i poprawności danych
  • Pobieranie listy wszystkich użytkowników z weryfikacją odpowiedzi 200
  • Pobieranie szczegółów użytkownika z obsługą błędu 404
  • Aktualizacja danych użytkownika
  • Usuwanie użytkownika z weryfikacją usunięcia

Obsługa błędów

  • Walidacja danych wejściowych
  • Obsługa nieistniejących użytkowników
  • Weryfikacja poprawności danych w bazie danych

Raportowanie i statystyki

GET /users

Opis:
Zwraca statystyki systemy rezerwacyjnego.

Odpowiedź:

  • 200: JSON ze statystykami.

Testy - statistics.test.ts

Kod testu:

import { describe, it, expect, beforeAll, afterAll, beforeEach } from "bun:test";
import { connectDB } from "../db";
import { Hotel } from "../models/hotel.model";
import { RoomPrice } from "../models/roomprice.model";
import { Reservation } from "../models/reservation.model";
import { User } from "../models/user.model";
import app from "../index";
import mongoose from "mongoose";

const baseUrl = "http://localhost/statistics";

beforeAll(async () => {
  await connectDB();
});

afterAll(async () => {
  await mongoose.connection.dropDatabase();
  await mongoose.connection.close();
});

beforeEach(async () => {
  await Hotel.deleteMany({});
  await RoomPrice.deleteMany({});
  await Reservation.deleteMany({});
  await User.deleteMany({});
});

describe("Statistics API integration", () => {
  it("should return correct statistics report", async () => {
    const user = await User.create({ name: "Stat User", email: "stat@user.pl" });
    const hotel = await Hotel.create({
      name: "Stat Hotel",
      city: "Stat City",
      address: "Stat Address",
      rooms: [
        { roomNumber: "1", type: "single", standard: "basic", isAvailable: true, currentPrice: 100 },
        { roomNumber: "2", type: "double", standard: "premium", isAvailable: false, currentPrice: 200 },
      ],
    });
    await RoomPrice.create([
      { hotelId: hotel._id, roomNumber: "1", fromDate: new Date(), toDate: new Date(), price: 100 },
      { hotelId: hotel._id, roomNumber: "2", fromDate: new Date(), toDate: new Date(), price: 200 },
    ]);
    await Reservation.create([
      {
        hotelId: hotel._id,
        roomNumber: "1",
        userId: user._id,
        fromDate: "2025-06-10",
        toDate: "2025-06-15",
        status: "confirmed",
      },
      {
        hotelId: hotel._id,
        roomNumber: "2",
        userId: user._id,
        fromDate: "2025-07-01",
        toDate: "2025-07-05",
        status: "cancelled",
      },
    ]);

    const res = await app.fetch(new Request(baseUrl));
    expect(res.status).toBe(200);
    const stats = (await res.json()) as any;

    expect(stats.hotelsCount).toBe(1);
    expect(stats.roomsCount).toBe(2);
    expect(stats.usersCount).toBe(1);
    expect(stats.reservationsCount).toBe(2);
    expect(stats.pricesCount).toBe(2);
    expect(stats.avgRoomPrice).toBe(150);
    expect(stats.minRoomPrice).toBe(100);
    expect(stats.maxRoomPrice).toBe(200);
    expect(stats.availableRooms).toBe(1);
    expect(typeof stats.reservationsByStatus.confirmed).toBe("number");
    expect(typeof stats.reservationsByStatus.cancelled).toBe("number");
    expect(Array.isArray(stats.reservationsPerHotel)).toBe(true);
    expect(stats.reservationsPerHotel.length).toBeGreaterThan(0);
  });
});

Opis:

Testy integracyjne dla API statystyk sprawdzają następujące funkcjonalności:

Podstawowe statystyki

  • Liczba hoteli
  • Liczba pokoi
  • Liczba użytkowników
  • Liczba rezerwacji
  • Liczba cen pokoi

Statystyki cen pokoi

  • Średnia cena pokoi
  • Minimalna cena pokoi
  • Maksymalna cena pokoi
  • Liczba dostępnych pokoi

Analiza rezerwacji

  • Podział rezerwacji według statusu
  • Statystyki rezerwacji per hotel

Testy weryfikują poprawność obliczeń statystyk dla przykładowych danych, w tym:

  • Jednego hotelu z dwoma pokojami
  • Jednego użytkownika
  • Dwóch rezerwacji (potwierdzonej i anulowanej)
  • Dwóch cen pokoi

About

Hotel Management System prototype

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors