diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 131392d..813de2c 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -31,5 +31,7 @@ jobs: run: dotnet restore src/Evently.Server/Evently.Server.csproj - name: Build run: dotnet build --no-restore src/Evently.Server/Evently.Server.csproj + - name: Run .NET Unit Tests + run: dotnet test tests/Evently.Server.Test/Evently.Server.Test.csproj - name: Test Docker Image run: docker build --tag=expo-connect/latest --file=src/Evently.Server/Dockerfile . \ No newline at end of file diff --git a/Evently.slnx b/Evently.slnx index 72397e4..5763060 100644 --- a/Evently.slnx +++ b/Evently.slnx @@ -1,13 +1,16 @@ - - - - - - - - - - - - + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Makefile b/Makefile index bc1b4f6..af3732a 100644 --- a/Makefile +++ b/Makefile @@ -5,7 +5,7 @@ test: dotnet test ./tests/Evently.Server.Test/VisualPatron.Server.Test.csproj add-migration: - dotnet ef migrations add RenameAccountId --project=src/Evently.Server --context=AppDbContext --output-dir=Common/Adapters/Data/Migrations + dotnet ef migrations add ColCollation --project=src/Evently.Server --context=AppDbContext --output-dir=Common/Adapters/Data/Migrations update-migration: dotnet ef database update --project=src/Evently.Server --context=AppDbContext diff --git a/src/Evently.Server/Common/Adapters/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Evently.Server/Common/Adapters/Data/Migrations/AppDbContextModelSnapshot.cs index f3b010e..97ff2d5 100644 --- a/src/Evently.Server/Common/Adapters/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Evently.Server/Common/Adapters/Data/Migrations/AppDbContextModelSnapshot.cs @@ -161,7 +161,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("GatheringId"); - b.ToTable("Bookings"); + b.ToTable("Bookings", (string)null); b.HasData( new @@ -199,7 +199,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("CategoryId"); - b.ToTable("Categories"); + b.ToTable("Categories", (string)null); b.HasData( new @@ -266,7 +266,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("GatheringId"); - b.ToTable("Gatherings"); + b.ToTable("Gatherings", (string)null); b.HasData( new @@ -448,7 +448,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasIndex("CategoryId"); - b.ToTable("GatheringCategoryDetails"); + b.ToTable("GatheringCategoryDetails", (string)null); b.HasData( new diff --git a/src/Evently.Server/Common/Extensions/ServiceContainerExtensions.cs b/src/Evently.Server/Common/Extensions/ServiceContainerExtensions.cs index c0babb8..51a1105 100644 --- a/src/Evently.Server/Common/Extensions/ServiceContainerExtensions.cs +++ b/src/Evently.Server/Common/Extensions/ServiceContainerExtensions.cs @@ -1,10 +1,9 @@ using Evently.Server.Common.Domains.Models; using Microsoft.Extensions.Options; -using System.Text.RegularExpressions; namespace Evently.Server.Common.Extensions; -public static partial class ServiceContainerExtensions { +public static class ServiceContainerExtensions { public static IOptions LoadAppConfiguration(this IServiceCollection services, ConfigurationManager configuration) { // load .env variables, in addition to appsettings.json that is loaded by default @@ -20,8 +19,4 @@ public static IOptions LoadAppConfiguration(this IServiceCollection se IOptions options = Options.Create(settings); return options; } - - - [GeneratedRegex("postgres://(.*):(.*)@(.*):(.*)/(.*)")] - private static partial Regex HerokuDbRegex(); } \ No newline at end of file diff --git a/src/Evently.Server/Features/Bookings/Controllers/BookingsController.cs b/src/Evently.Server/Features/Bookings/Controllers/BookingsController.cs index 8c90526..fd74014 100644 --- a/src/Evently.Server/Features/Bookings/Controllers/BookingsController.cs +++ b/src/Evently.Server/Features/Bookings/Controllers/BookingsController.cs @@ -67,12 +67,18 @@ public async Task> CreateBooking([FromBody] BookingReqDto [HttpPut("{bookingId}", Name = "UpdateBooking")] public async Task UpdateBooking(string bookingId, [FromBody] BookingReqDto bookingReqDto) { - bool isExist = await bookingService.Exists(bookingId); - if (!isExist) { + Booking? booking = await bookingService.GetBooking(bookingId); + if (booking is null) { return NotFound(); } - Booking booking = await bookingService.UpdateBooking(bookingId, bookingReqDto); + bool isAuth = await this.IsResourceOwner(booking.AttendeeId); + logger.LogInformation("isAuth: {}", isAuth); + if (!isAuth) { + return Forbid(); + } + + booking = await bookingService.UpdateBooking(bookingId, bookingReqDto); return Ok(booking); } diff --git a/src/evently.client/README.md b/src/evently.client/README.md index 471ca19..5cc620e 100644 --- a/src/evently.client/README.md +++ b/src/evently.client/README.md @@ -4,8 +4,10 @@ This template provides a minimal setup to get React working in Vite with HMR and Currently, two official plugins are available: -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) + uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) + uses [SWC](https://swc.rs/) for Fast Refresh ## Expanding the ESLint configuration @@ -39,7 +41,10 @@ export default tseslint.config([ ]); ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +You can also +install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) +and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) +for React-specific lint rules: ```js // eslint.config.js diff --git a/src/evently.client/evently.client.esproj b/src/evently.client/evently.client.esproj index 2b10131..ace1e0d 100644 --- a/src/evently.client/evently.client.esproj +++ b/src/evently.client/evently.client.esproj @@ -1,18 +1,18 @@ - - - - - false - pnpm run dev - src\ - Vitest - - false - - pnpm run build - pnpm run build - - $(MSBuildProjectDirectory)\dist - + + + + + false + pnpm run dev + src\ + Vitest + + false + + pnpm run build + pnpm run build + + $(MSBuildProjectDirectory)\dist + \ No newline at end of file diff --git a/src/evently.client/index.html b/src/evently.client/index.html index 5bd53ac..bb4e300 100644 --- a/src/evently.client/index.html +++ b/src/evently.client/index.html @@ -1,16 +1,16 @@ - + - + Evently
- + diff --git a/src/evently.client/public/vite.svg b/src/evently.client/public/vite.svg index e7b8dfb..2184d4b 100644 --- a/src/evently.client/public/vite.svg +++ b/src/evently.client/public/vite.svg @@ -1 +1,18 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/evently.client/src/lib/assets/GoogleIcon.svg b/src/evently.client/src/lib/assets/GoogleIcon.svg index 43cb98a..27e5a4c 100644 --- a/src/evently.client/src/lib/assets/GoogleIcon.svg +++ b/src/evently.client/src/lib/assets/GoogleIcon.svg @@ -1,18 +1,18 @@ - - - - + + + + diff --git a/src/evently.client/src/lib/assets/react.svg b/src/evently.client/src/lib/assets/react.svg index 6c87de9..abc768c 100644 --- a/src/evently.client/src/lib/assets/react.svg +++ b/src/evently.client/src/lib/assets/react.svg @@ -1 +1,5 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/src/evently.client/src/lib/components/render-with-test-providers.tsx b/src/evently.client/src/lib/components/render-with-test-providers.tsx index 47b1125..405f14b 100644 --- a/src/evently.client/src/lib/components/render-with-test-providers.tsx +++ b/src/evently.client/src/lib/components/render-with-test-providers.tsx @@ -1,14 +1,14 @@ import React from "react"; import { - Outlet, - RouterProvider, createMemoryHistory, createRootRoute, createRoute, - createRouter + createRouter, + Outlet, + RouterProvider } from "@tanstack/react-router"; import { render, screen } from "@testing-library/react"; -import { QueryClientProvider, QueryClient } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; type RenderOptions = { pathPattern: string; diff --git a/src/evently.client/src/lib/services/gathering-service.mock.ts b/src/evently.client/src/lib/services/gathering-service.mock.ts index e380ace..04615d3 100644 --- a/src/evently.client/src/lib/services/gathering-service.mock.ts +++ b/src/evently.client/src/lib/services/gathering-service.mock.ts @@ -1,4 +1,4 @@ -import { Booking, Gathering, Category, GatheringCategoryDetail } from "~/lib/domains/entities"; +import { Booking, Category, Gathering, GatheringCategoryDetail } from "~/lib/domains/entities"; import { GatheringReqDto } from "~/lib/domains/models"; import type { GetGatheringsParams } from "./gathering-service"; import type { PageResult } from "~/lib/domains/interfaces"; diff --git a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/scanner.tsx b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/scanner.tsx index 6754c76..c00b3b9 100644 --- a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/scanner.tsx +++ b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/-components/scanner.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useState, type JSX } from "react"; +import { type JSX, useEffect, useRef, useState } from "react"; import QrScanner from "qr-scanner"; interface ScannerProps { diff --git a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.index.tsx b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.index.tsx index eceaa69..d0f1bf4 100644 --- a/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.index.tsx +++ b/src/evently.client/src/routes/bookings/(auth)/hosting/$gatheringId/dashboard.index.tsx @@ -1,12 +1,17 @@ import { Booking, Gathering } from "~/lib/domains/entities"; -import { getBookings, type GetBookingsParams, getGathering, toIsoString } from "~/lib/services"; +import { + downloadFile, + getBookings, + type GetBookingsParams, + getGathering, + toIsoString +} from "~/lib/services"; import { createFileRoute, Link } from "@tanstack/react-router"; -import { useState, type JSX } from "react"; +import { type JSX, useState } from "react"; import uniqby from "lodash.uniqby"; import cloneDeep from "lodash.clonedeep"; import { Icon } from "@iconify/react"; import { json2csv } from "json-2-csv"; -import { downloadFile } from "~/lib/services"; import { useQuery } from "@tanstack/react-query"; import { BookingsTable, Jumbotron, StatsCard } from "./-components"; import { useInterval } from "usehooks-ts"; diff --git a/src/evently.client/src/routes/gatherings/$gatheringId/(auth).update.tsx b/src/evently.client/src/routes/gatherings/$gatheringId/(auth).update.tsx index ceee85a..7339be6 100644 --- a/src/evently.client/src/routes/gatherings/$gatheringId/(auth).update.tsx +++ b/src/evently.client/src/routes/gatherings/$gatheringId/(auth).update.tsx @@ -1,6 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { Category, Gathering } from "~/lib/domains/entities"; -import { useEffect, useState, type JSX } from "react"; +import { type JSX, useEffect, useState } from "react"; import { fetchFile, getCategories, @@ -10,8 +10,8 @@ import { updateGathering } from "~/lib/services"; import { - useGatheringForm, - type GatheringForm as IGatheringForm + type GatheringForm as IGatheringForm, + useGatheringForm } from "~/routes/gatherings/-services"; import { GatheringReqDto, ToastContent } from "~/lib/domains/models"; import { GatheringForm } from "~/routes/gatherings/-components"; diff --git a/src/evently.client/src/routes/gatherings/$gatheringId/-components/jumbotron.tsx b/src/evently.client/src/routes/gatherings/$gatheringId/-components/jumbotron.tsx index 63c42a7..2ad3ee0 100644 --- a/src/evently.client/src/routes/gatherings/$gatheringId/-components/jumbotron.tsx +++ b/src/evently.client/src/routes/gatherings/$gatheringId/-components/jumbotron.tsx @@ -1,6 +1,6 @@ import type { JSX } from "react"; import { Icon } from "@iconify/react"; -import { Gathering, Category, Booking } from "~/lib/domains/entities"; +import { Booking, Category, Gathering } from "~/lib/domains/entities"; import { DateTime } from "luxon"; interface JumbotronProps { diff --git a/src/evently.client/src/routes/gatherings/$gatheringId/-components/qr-dialog.tsx b/src/evently.client/src/routes/gatherings/$gatheringId/-components/qr-dialog.tsx index cbecb10..87f3263 100644 --- a/src/evently.client/src/routes/gatherings/$gatheringId/-components/qr-dialog.tsx +++ b/src/evently.client/src/routes/gatherings/$gatheringId/-components/qr-dialog.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, type JSX, type Ref } from "react"; +import { type JSX, type Ref, useEffect, useRef } from "react"; import { Booking } from "~/lib/domains/entities"; import QRCode from "qrcode"; diff --git a/src/evently.client/src/routes/gatherings/$gatheringId/index.tsx b/src/evently.client/src/routes/gatherings/$gatheringId/index.tsx index bd34118..0f6706b 100644 --- a/src/evently.client/src/routes/gatherings/$gatheringId/index.tsx +++ b/src/evently.client/src/routes/gatherings/$gatheringId/index.tsx @@ -1,4 +1,4 @@ -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, useNavigate } from "@tanstack/react-router"; import { type JSX, useRef } from "react"; import { Booking, Gathering } from "~/lib/domains/entities"; import { @@ -11,7 +11,6 @@ import { } from "~/lib/services"; import { useMutation } from "@tanstack/react-query"; import { BookingReqDto, GatheringReqDto } from "~/lib/domains/models"; -import { useNavigate } from "@tanstack/react-router"; import { CancellationDialog, Jumbotron, QrDialog } from "./-components"; import Placeholder1 from "~/lib/assets/event_placeholder_1.webp"; import Placeholder2 from "~/lib/assets/event_placeholder_2.png"; diff --git a/src/evently.client/src/routes/gatherings/(auth).create.tsx b/src/evently.client/src/routes/gatherings/(auth).create.tsx index a54014f..7053609 100644 --- a/src/evently.client/src/routes/gatherings/(auth).create.tsx +++ b/src/evently.client/src/routes/gatherings/(auth).create.tsx @@ -1,8 +1,8 @@ import { createFileRoute } from "@tanstack/react-router"; import { Category, Gathering } from "~/lib/domains/entities"; -import { useState, type JSX } from "react"; +import { type JSX, useState } from "react"; import { createGathering, getCategories, guardRoute, sleep } from "~/lib/services"; -import { useGatheringForm, type GatheringForm as IGatheringForm } from "./-services"; +import { type GatheringForm as IGatheringForm, useGatheringForm } from "./-services"; import { GatheringReqDto, ToastContent } from "~/lib/domains/models"; import { GatheringForm } from "~/routes/gatherings/-components"; diff --git a/src/evently.client/src/routes/gatherings/-components/gathering-form.tsx b/src/evently.client/src/routes/gatherings/-components/gathering-form.tsx index 07f5df7..72ac6b6 100644 --- a/src/evently.client/src/routes/gatherings/-components/gathering-form.tsx +++ b/src/evently.client/src/routes/gatherings/-components/gathering-form.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, type JSX } from "react"; +import { type JSX, useEffect, useState } from "react"; import { compressImage, type GatheringForm as IGatheringForm } from "../-services"; import { FieldErrMsg as FieldInfo } from "~/lib/components"; import { Icon } from "@iconify/react"; diff --git a/src/evently.client/src/routes/gatherings/-index.test.tsx b/src/evently.client/src/routes/gatherings/-index.test.tsx index 021b44c..c78cf82 100644 --- a/src/evently.client/src/routes/gatherings/-index.test.tsx +++ b/src/evently.client/src/routes/gatherings/-index.test.tsx @@ -1,10 +1,10 @@ import { render, screen, waitFor } from "@testing-library/react"; import { getMockGatherings } from "~/lib/services/gathering-service.mock"; +import type { GetGatheringsParams } from "~/lib/services"; import * as GatheringService from "~/lib/services"; import userEvent from "@testing-library/user-event"; import { TestWrapper, WrapperDataTestId } from "~/lib/components"; import { GatheringsPage } from "./index.tsx"; -import type { GetGatheringsParams } from "~/lib/services"; it("renders GatheringPage", async () => { const spy = vi.spyOn(GatheringService, "getGatherings"); diff --git a/src/evently.client/src/routes/healthcheck/-index.test.tsx b/src/evently.client/src/routes/healthcheck/-index.test.tsx index a5a6540..39b7a5b 100644 --- a/src/evently.client/src/routes/healthcheck/-index.test.tsx +++ b/src/evently.client/src/routes/healthcheck/-index.test.tsx @@ -1,5 +1,5 @@ import { render, screen, waitFor } from "@testing-library/react"; -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as healthCheckService from "./-services/health-check-service"; import { HealthcheckPage } from "./index.tsx"; import { TestWrapper, WrapperDataTestId } from "~/lib/components"; diff --git a/src/evently.client/src/routes/login/index.tsx b/src/evently.client/src/routes/login/index.tsx index 002592a..cc1e2e1 100644 --- a/src/evently.client/src/routes/login/index.tsx +++ b/src/evently.client/src/routes/login/index.tsx @@ -1,7 +1,7 @@ import { login } from "~/lib/services/auth-service.ts"; import GoogleIcon from "~/lib/assets/GoogleIcon.svg"; -import { createFileRoute } from "@tanstack/react-router"; -import { useSearch } from "@tanstack/react-router"; +import { createFileRoute, useSearch } from "@tanstack/react-router"; + export const Route = createFileRoute("/login/")({ component: LoginPage }); diff --git a/src/evently.client/tsconfig.app.json b/src/evently.client/tsconfig.app.json index 6ff0d73..92d1d72 100644 --- a/src/evently.client/tsconfig.app.json +++ b/src/evently.client/tsconfig.app.json @@ -7,7 +7,6 @@ "module": "ESNext", "skipLibCheck": true, "types": ["vitest/globals", "@testing-library/jest-dom"], - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, @@ -15,7 +14,6 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", - /* Linting */ "strict": true, "noUnusedLocals": true, @@ -23,10 +21,8 @@ "erasableSyntaxOnly": false, "noFallthroughCasesInSwitch": true, "noUncheckedSideEffectImports": true, - "emitDecoratorMetadata": true, "experimentalDecorators": true, - /* Path alias */ "baseUrl": ".", "paths": { diff --git a/src/evently.client/tsconfig.node.json b/src/evently.client/tsconfig.node.json index 1a5ed45..d8b3f55 100644 --- a/src/evently.client/tsconfig.node.json +++ b/src/evently.client/tsconfig.node.json @@ -5,14 +5,12 @@ "lib": ["ES2023"], "module": "ESNext", "skipLibCheck": true, - /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, - /* Linting */ "strict": true, "noUnusedLocals": true, diff --git a/tests/Evently.Server.Test/Common/Extensions/MapperExtensionTests.cs b/tests/Evently.Server.Test/Common/Extensions/MapperExtensionTests.cs new file mode 100644 index 0000000..45241fd --- /dev/null +++ b/tests/Evently.Server.Test/Common/Extensions/MapperExtensionTests.cs @@ -0,0 +1,126 @@ +using Evently.Server.Common.Domains.Entities; +using Evently.Server.Common.Domains.Models; +using Evently.Server.Common.Extensions; + +namespace Evently.Server.Test.Common.Extensions; + +public class MapperExtensionTests { + [Fact] + public void TestMapToGathering() { + // Arrange (mock values) + DateTimeOffset start = DateTimeOffset.UtcNow.AddDays(3); + DateTimeOffset end = start.AddHours(2); + + GatheringReqDto dto = new( + GatheringId: 0, + "Mock Gathering", + "Mock Description", + start, + end, + CancellationDateTime: null, + "Mock Location", + "organizer-mock", + "mock-cover.jpg", + GatheringCategoryDetails: [] + ); + + // Act + Gathering entity = dto.ToGathering(); + + // Assert + Assert.NotNull(entity); + Assert.Equal(dto.Name, entity.Name); + Assert.Equal(dto.Description, entity.Description); + Assert.Equal(dto.Start, entity.Start); + Assert.Equal(dto.End, entity.End); + Assert.Equal(dto.CancellationDateTime, entity.CancellationDateTime); + Assert.Equal(dto.Location, entity.Location); + Assert.Equal(dto.OrganiserId, entity.OrganiserId); + Assert.Equal(dto.CoverSrc, entity.CoverSrc); + Assert.NotNull(entity.GatheringCategoryDetails); + Assert.Empty(entity.GatheringCategoryDetails); + } + + + [Fact] + public void TestMapToBooking() { + // Arrange (mock values) + const string attendeeId = "attendee-mock"; + const string bookingId = "book_mock"; + const long gatheringId = 42L; + + DateTimeOffset creation = DateTimeOffset.UtcNow.AddDays(1); + DateTimeOffset checkIn = creation.AddHours(1); + DateTimeOffset checkout = creation.AddHours(2); + DateTimeOffset cancellation = creation.AddHours(3); + + // Create DTO with mock values + BookingReqDto dto = new(attendeeId, bookingId, gatheringId, creation, checkIn, checkout, cancellation); + + // Act + Booking booking = dto.ToBooking(); + + // Assert: direct field comparisons (no reflection) + Assert.NotNull(booking); + Assert.Equal(dto.AttendeeId, booking.AttendeeId); + Assert.Equal(dto.GatheringId, booking.GatheringId); + Assert.Equal(dto.CreationDateTime, booking.CreationDateTime); + Assert.Equal(dto.CheckInDateTime, booking.CheckInDateTime); + Assert.Equal(dto.CheckoutDateTime, booking.CheckoutDateTime); + Assert.Equal(dto.CancellationDateTime, booking.CancellationDateTime); + + // If BookingId is part of the mapping, also validate it: + // Assert.Equal(dto.BookingId, booking.BookingId); + } + + [Fact] + public void TestMapToBookingDto() { + // Arrange (mock values) + string attendeeId = "attendee-mock"; + long gatheringId = 7L; + + DateTimeOffset creation = DateTimeOffset.UtcNow.AddDays(2); + DateTimeOffset checkIn = creation.AddHours(1); + DateTimeOffset checkout = creation.AddHours(2); + DateTimeOffset? cancellation = null; + + Booking booking = new() { + AttendeeId = attendeeId, + GatheringId = gatheringId, + CreationDateTime = creation, + CheckInDateTime = checkIn, + CheckoutDateTime = checkout, + CancellationDateTime = cancellation, + }; + + // Act + BookingReqDto dto = booking.ToBookingDto(); + + // Assert: direct field comparisons (no reflection) + Assert.NotNull(dto); + Assert.Equal(booking.AttendeeId, dto.AttendeeId); + Assert.Equal(booking.GatheringId, dto.GatheringId); + Assert.Equal(booking.CreationDateTime, dto.CreationDateTime); + Assert.Equal(booking.CheckInDateTime, dto.CheckInDateTime); + Assert.Equal(booking.CheckoutDateTime, dto.CheckoutDateTime); + Assert.Equal(booking.CancellationDateTime, dto.CancellationDateTime); + } + + + [Fact] + public void TestMapToAccountDto() { + // Arrange (mock values) + Account account = new() { + Id = "acc_mock", + Email = "mock@example.com", + }; + + // Act + AccountDto accountDto = account.ToAccountDto(); + + // Assert: direct field comparisons (no reflection) + Assert.NotNull(accountDto); + Assert.Equal(account.Id, accountDto.Id); + Assert.Equal(account.Email, accountDto.Email); + } +} \ No newline at end of file diff --git a/tests/Evently.Server.Test/Evently.Server.Test.csproj b/tests/Evently.Server.Test/Evently.Server.Test.csproj new file mode 100644 index 0000000..9b7a4dc --- /dev/null +++ b/tests/Evently.Server.Test/Evently.Server.Test.csproj @@ -0,0 +1,28 @@ + + + + net9.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/Evently.Server.Test/Features/Bookings/Services/BookingServiceTests.cs b/tests/Evently.Server.Test/Features/Bookings/Services/BookingServiceTests.cs new file mode 100644 index 0000000..3801900 --- /dev/null +++ b/tests/Evently.Server.Test/Features/Bookings/Services/BookingServiceTests.cs @@ -0,0 +1,142 @@ +using Evently.Server.Common.Adapters.Data; +using Evently.Server.Common.Domains.Entities; +using Evently.Server.Common.Domains.Interfaces; +using Evently.Server.Common.Domains.Models; +using Evently.Server.Features.Bookings.Services; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; +using Moq; + +namespace Evently.Server.Test.Features.Bookings.Services; + +public class BookingServiceTests : IDisposable { + private readonly IBookingService _bookingService; + private readonly SqliteConnection _conn; + private readonly AppDbContext _dbContext; + + public BookingServiceTests() { + _conn = new SqliteConnection("Filename=:memory:"); + _conn.Open(); + + // These options will be used by the context instances in this test suite, including the connection opened above. + DbContextOptions contextOptions = new DbContextOptionsBuilder() + .UseSqlite(_conn) + .Options; + + // Create the schema and seed some data + AppDbContext dbContext = new(contextOptions); + + dbContext.Database.EnsureCreated(); + _dbContext = dbContext; + + Mock mediaRendererMock = new(); + Mock fileStorageServiceMock = new(); + + _bookingService = new BookingService(mediaRendererMock.Object, fileStorageServiceMock.Object, validator: new BookingValidator(), _dbContext); + } + + public void Dispose() { + _dbContext.Dispose(); + _conn.Dispose(); + } + + [Fact] + public async Task CreateBooking_WithValidData_ShouldCreateBooking() { + DateTimeOffset now = DateTimeOffset.Now; + // Arrange + BookingReqDto bookingReqDto = new( + "book_abc", + GatheringId: 1, + AttendeeId: "empty-user-12345", + CancellationDateTime: null, + CheckInDateTime: null, + CheckoutDateTime: null, + CreationDateTime: now + ); + + // Act + Booking result = await _bookingService.CreateBooking(bookingReqDto); + + // Assert + Assert.NotNull(result); + Assert.NotNull(result.BookingId); + Assert.Equal(bookingReqDto.GatheringId, result.GatheringId); + Assert.Equal(bookingReqDto.AttendeeId, result.AttendeeId); + Assert.Equal(bookingReqDto.CancellationDateTime, result.CancellationDateTime); + Assert.Equal(bookingReqDto.CheckInDateTime, result.CheckInDateTime); + Assert.Equal(bookingReqDto.CheckoutDateTime, result.CheckoutDateTime); + Assert.Equal(bookingReqDto.CreationDateTime, result.CreationDateTime); + } + + [Fact] + public async Task CreateBooking_WithEmptyAttendeeId_ShouldThrowException() { + DateTimeOffset now = DateTimeOffset.Now; + // Arrange + BookingReqDto invalidBookingReqDto = new( + "book_abc", + GatheringId: 1, + AttendeeId: "", + CancellationDateTime: null, + CheckInDateTime: null, + CheckoutDateTime: null, + CreationDateTime: now + ); + + // Act & Assert + await Assert.ThrowsAsync(() => _bookingService.CreateBooking(invalidBookingReqDto)); + } + + [Fact] + public async Task GetBooking_WithValidBookingId_ShouldReturnBooking() { + // Act + Booking? result = await _bookingService.GetBooking("book_abc123456"); + + // Assert + Assert.NotNull(result); + Assert.Equal("book_abc123456", result.BookingId); + } + + [Fact] + public async Task UpdateBooking_WithNonExistentBookingId_ShouldThrowKeyNotFoundException() { + // Arrange + string nonExistentBookingId = "book_nonexistent"; + BookingReqDto updateRequest = new( + nonExistentBookingId, + GatheringId: 1, + AttendeeId: "user_test", + CancellationDateTime: null, + CheckInDateTime: null, + CheckoutDateTime: null, + CreationDateTime: DateTimeOffset.Now + ); + + // Act & Assert + await Assert.ThrowsAsync(() => + _bookingService.UpdateBooking(nonExistentBookingId, updateRequest)); + } + + [Fact] + public async Task UpdateBooking_WithCancellation_ShouldUpdateCancellationDateTime() { + // Arrange + DateTimeOffset cancellationTime = DateTimeOffset.Now.AddMinutes(30); + Booking? booking = await _bookingService.GetBooking("book_abc123456"); + Assert.NotNull(booking); + + BookingReqDto updateRequest = new( + booking.BookingId, + GatheringId: booking.GatheringId, + AttendeeId: booking.AttendeeId, + CancellationDateTime: cancellationTime, + CheckInDateTime: null, + CheckoutDateTime: null, + CreationDateTime: booking.CreationDateTime + ); + + // Act + booking = await _bookingService.UpdateBooking("book_abc123456", updateRequest); + + // Assert + Assert.NotNull(booking); + Assert.Equal(cancellationTime, booking.CancellationDateTime); + } +} \ No newline at end of file diff --git a/tests/Evently.Server.Test/Features/Gatherings/Services/GatheringServiceTests.cs b/tests/Evently.Server.Test/Features/Gatherings/Services/GatheringServiceTests.cs new file mode 100644 index 0000000..690a0b7 --- /dev/null +++ b/tests/Evently.Server.Test/Features/Gatherings/Services/GatheringServiceTests.cs @@ -0,0 +1,275 @@ +using Evently.Server.Common.Adapters.Data; +using Evently.Server.Common.Domains.Entities; +using Evently.Server.Common.Domains.Interfaces; +using Evently.Server.Common.Domains.Models; +using Evently.Server.Features.Gatherings.Services; +using Microsoft.Data.Sqlite; +using Microsoft.EntityFrameworkCore; + +namespace Evently.Server.Test.Features.Gatherings.Services; + +public class GatheringServiceTests : IDisposable { + private readonly SqliteConnection _conn; + private readonly AppDbContext _dbContext; + private readonly IGatheringService _gatheringService; + + public GatheringServiceTests() { + _conn = new SqliteConnection("Filename=:memory:"); + _conn.Open(); + + // These options will be used by the context instances in this test suite, including the connection opened above. + DbContextOptions contextOptions = new DbContextOptionsBuilder() + .UseSqlite(_conn) + .Options; + + // Create the schema and seed some data + AppDbContext dbContext = new(contextOptions); + + dbContext.Database.EnsureCreated(); + _dbContext = dbContext; + + _gatheringService = new GatheringService(_dbContext, validator: new GatheringValidator()); + } + + public void Dispose() { + _dbContext.Dispose(); + _conn.Dispose(); + } + + [Fact] + public async Task CreateGathering_WithValidData_ShouldCreateGathering() { + // Arrange + GatheringReqDto gatheringReqDto = new( + GatheringId: 0, + "Test Gathering", + "Test Description", + Start: DateTimeOffset.UtcNow.AddDays(1), + End: DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + CancellationDateTime: null, + "Test Location", + "organizer123", + "test-cover.jpg", + GatheringCategoryDetails: [] + ); + + // Act + Gathering result = await _gatheringService.CreateGathering(gatheringReqDto); + + // Assert + Assert.NotNull(result); + Assert.Equal(gatheringReqDto.Name, result.Name); + Assert.Equal(gatheringReqDto.Description, result.Description); + Assert.Equal(gatheringReqDto.Start, result.Start); + Assert.Equal(gatheringReqDto.End, result.End); + Assert.Equal(gatheringReqDto.Location, result.Location); + Assert.Equal(gatheringReqDto.OrganiserId, result.OrganiserId); + + // Verify it was saved to database + Gathering? savedGathering = await _dbContext.Gatherings.FirstOrDefaultAsync(g => g.GatheringId == result.GatheringId); + Assert.NotNull(savedGathering); + } + + [Fact] + public async Task CreateGathering_WithInvalidData_ShouldThrowArgumentException() { + // Arrange + GatheringReqDto invalidGatheringReqDto = new( + GatheringId: 0, + "", // Invalid empty name + "Test Description", + Start: DateTimeOffset.UtcNow.AddDays(1), + End: DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + CancellationDateTime: null, + "Test Location", + "organizer123", + CoverSrc: null, + GatheringCategoryDetails: [] + ); + + // Act & Assert + await Assert.ThrowsAsync(() => _gatheringService.CreateGathering(invalidGatheringReqDto)); + } + + [Fact] + public async Task GetGathering_WithExistingId_ShouldReturnGathering() { + // Arrange + Gathering gathering = new() { + Name = "Test Gathering", + Description = "Test Description", + Start = DateTimeOffset.UtcNow.AddDays(1), + End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + Location = "Test Location", + OrganiserId = "organizer123", + Bookings = [], + GatheringCategoryDetails = [], + }; + + _dbContext.Gatherings.Add(gathering); + await _dbContext.SaveChangesAsync(); + + // Act + Gathering? result = await _gatheringService.GetGathering(gathering.GatheringId); + + // Assert + Assert.NotNull(result); + Assert.Equal(gathering.GatheringId, result.GatheringId); + Assert.Equal(gathering.Name, result.Name); + Assert.Equal(gathering.Description, result.Description); + } + + [Fact] + public async Task GetGathering_WithNonExistentId_ShouldReturnNull() { + // Arrange + const long nonExistentId = 999; + + // Act + Gathering? result = await _gatheringService.GetGathering(nonExistentId); + + // Assert + Assert.Null(result); + } + + [Fact(Skip = "This test is temporarily disabled because of issue: https://github.com/npgsql/efcore.pg/issues/1649")] + public async Task GetGatherings_WithNameFilter_ShouldReturnFilteredResults() { + // Arrange + List gatherings = [ + new() { + Name = "Tech Conference", + Description = "Description 1", + Start = DateTimeOffset.UtcNow.AddDays(1), + End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + Location = "Location 1", + OrganiserId = "organizer1", + Bookings = [], + GatheringCategoryDetails = [], + }, + + new() { + Name = "Art Workshop", + Description = "Description 2", + Start = DateTimeOffset.UtcNow.AddDays(2), + End = DateTimeOffset.UtcNow.AddDays(2).AddHours(2), + Location = "Location 2", + OrganiserId = "organizer2", + Bookings = [], + GatheringCategoryDetails = [], + }, + + ]; + + _dbContext.Gatherings.AddRange(gatherings); + await _dbContext.SaveChangesAsync(); + + // Act + PageResult result = await _gatheringService.GetGatherings(attendeeId: null, + organiserId: null, + "Tech", + startDateBefore: null, + startDateAfter: null, + endDateBefore: null, + endDateAfter: null, + isCancelled: null, + offset: null, + limit: null); + + // Assert + Assert.NotNull(result); + Assert.Equal(expected: 1, result.TotalCount); + Assert.Equal("Tech Conference", result.Items.First().Name); + } + + [Fact] + public async Task UpdateGathering_WithValidData_ShouldUpdateGathering() { + // Arrange + Gathering gathering = new() { + Name = "Original Name", + Description = "Original Description", + Start = DateTimeOffset.UtcNow.AddDays(1), + End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + Location = "Original Location", + OrganiserId = "organizer123", + GatheringCategoryDetails = [], + }; + + _dbContext.Gatherings.Add(gathering); + await _dbContext.SaveChangesAsync(); + + GatheringReqDto updateDto = new( + gathering.GatheringId, + "Updated Name", + "Updated Description", + Start: DateTimeOffset.UtcNow.AddDays(2), + End: DateTimeOffset.UtcNow.AddDays(2).AddHours(3), + CancellationDateTime: null, + "Updated Location", + "organizer123", + "updated-cover.jpg", + GatheringCategoryDetails: [] + ); + + // Act + Gathering result = await _gatheringService.UpdateGathering(gathering.GatheringId, updateDto); + + // Assert + Assert.NotNull(result); + Assert.Equal("Updated Name", result.Name); + Assert.Equal("Updated Description", result.Description); + Assert.Equal(updateDto.Start, result.Start); + Assert.Equal(updateDto.End, result.End); + Assert.Equal("Updated Location", result.Location); + Assert.Equal("updated-cover.jpg", result.CoverSrc); + } + + [Fact] + public async Task UpdateGathering_WithNonExistentId_ShouldThrowKeyNotFoundException() { + // Arrange + GatheringReqDto updateDto = new( + GatheringId: 999, + "Updated Name", + "Updated Description", + Start: DateTimeOffset.UtcNow.AddDays(2), + End: DateTimeOffset.UtcNow.AddDays(2).AddHours(3), + CancellationDateTime: null, + "Updated Location", + "organizer123", + CoverSrc: null, + GatheringCategoryDetails: [] + ); + + // Act & Assert + await Assert.ThrowsAsync(() => _gatheringService.UpdateGathering(gatheringId: 999, updateDto)); + } + + [Fact] + public async Task DeleteGathering_WithExistingId_ShouldDeleteGathering() { + // Arrange + Gathering gathering = new() { + Name = "Test Gathering", + Description = "Test Description", + Start = DateTimeOffset.UtcNow.AddDays(1), + End = DateTimeOffset.UtcNow.AddDays(1).AddHours(2), + Location = "Test Location", + OrganiserId = "organizer123", + GatheringCategoryDetails = [], + }; + + _dbContext.Gatherings.Add(gathering); + await _dbContext.SaveChangesAsync(); + long gatheringId = gathering.GatheringId; + + // Act + await _gatheringService.DeleteGathering(gatheringId); + + // Assert + Gathering? deletedGathering = await _dbContext.Gatherings.FirstOrDefaultAsync(g => g.GatheringId == gatheringId); + Assert.Null(deletedGathering); + } + + [Fact] + public async Task DeleteGathering_WithNonExistentId_ShouldThrowInvalidOperationException() { + // Arrange + const long nonExistentId = 999; + + // Act & Assert + await Assert.ThrowsAsync(() => _gatheringService.DeleteGathering(nonExistentId)); + } +} \ No newline at end of file