Mateusz Bis, Maciej Słowik
Link do repozytorium: https://github.com/Matb85/hms-agh-2025
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.
bun installdocker compose up # uruchomienie MongoDB
bun openapi # generacja dokumentacji dla Swagger UI
bun dev # uruchomienie serwerabun testbun seed # Wypełnienie bazy danych losowymi danymiZaprojektowana 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)
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.
Hotelz 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.
- 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.
- 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.
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).
Opis:
Zwraca listę wszystkich hoteli.
parametry wyszukiwania
- id: string
- name: string
Odpowiedź:
- 200: Tablica obiektów hotelu.
Opis:
Zwraca szczegóły hotelu o podanym ID.
Odpowiedź:
- 200: Obiekt hotelu.
- 404: Nie znaleziono hotelu.
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.
Opis:
Aktualizuje dane hotelu o podanym ID.
Body:
Dowolne pola hotelu do aktualizacji.
Odpowiedź:
- 200: Zaktualizowany hotel.
Opis:
Usuwa hotel o podanym ID.
Odpowiedź:
- 200: Tekst "Deleted".
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.
Opis:
Dodaje pokój do hotelu.
Body:
Dane pokoju.
Odpowiedź:
- 200: Zaktualizowany hotel z nowym pokojem.
- 404: Nie znaleziono hotelu.
Opis:
Aktualizuje dane pokoju w hotelu.
Body:
Dane do aktualizacji pokoju.
Odpowiedź:
- 200: Zaktualizowany pokój.
- 404: Nie znaleziono hotelu lub pokoju.
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:
- 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
- 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
Opis:
Zwraca listę wszystkich cen pokoi.
Odpowiedź:
- 200: Tablica obiektów RoomPrice.
Opis:
Zwraca szczegóły ceny pokoju o podanym ID.
Odpowiedź:
- 200: Obiekt RoomPrice.
- 404: Nie znaleziono ceny.
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.
Opis:
Aktualizuje cenę pokoju o podanym ID.
Body:
Dane do aktualizacji.
Odpowiedź:
- 200: Zaktualizowana cena pokoju.
Opis:
Usuwa cenę pokoju o podanym ID.
Odpowiedź:
- 200: Tekst "Deleted".
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:
- 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
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.
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.
Opis:
Usuwa rezerwację o podanym ID.
Odpowiedź:
- 200:
{ message: "Reservation canceled" } - 404: Nie znaleziono rezerwacji.
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:
- 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
- Zapobieganie równoległym rezerwacjom tego samego pokoju
- Sprawdzanie nakładania się dat rezerwacji (409)
- Weryfikacja unikalności rezerwacji dla danego pokoju
- Filtrowanie po ID hotelu
- Filtrowanie po ID użytkownika
- Filtrowanie po zakresie dat (nakładające się rezerwacje)
- Filtrowanie po statusie rezerwacji
- Walidacja danych wejściowych
- Obsługa nieistniejących użytkowników
- Obsługa nakładających się rezerwacji
- Weryfikacja poprawności dat
Opis:
Zwraca listę wszystkich użytkowników.
Odpowiedź:
- 200: Tablica obiektów użytkownika.
Opis:
Zwraca szczegóły użytkownika o podanym ID.
Odpowiedź:
- 200: Obiekt użytkownika.
- 404: Nie znaleziono użytkownika.
Opis:
Tworzy nowego użytkownika.
Body:
const body = {
name: "string",
email: "string",
phone: "string",
};Odpowiedź:
- 201: Utworzony użytkownik.
- 400: Błędne dane.
Opis:
Aktualizuje dane użytkownika o podanym ID.
Body:
Dowolne pola użytkownika do aktualizacji.
Odpowiedź:
- 200: Zaktualizowany użytkownik.
Opis:
Usuwa użytkownika o podanym ID.
Odpowiedź:
- 200: Tekst "Deleted".
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:
- 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
- Walidacja danych wejściowych
- Obsługa nieistniejących użytkowników
- Weryfikacja poprawności danych w bazie danych
Opis:
Zwraca statystyki systemy rezerwacyjnego.
Odpowiedź:
- 200: JSON ze statystykami.
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:
- Liczba hoteli
- Liczba pokoi
- Liczba użytkowników
- Liczba rezerwacji
- Liczba cen pokoi
- Średnia cena pokoi
- Minimalna cena pokoi
- Maksymalna cena pokoi
- Liczba dostępnych pokoi
- 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