diff --git a/.gitignore b/.gitignore index 2fa01c4..e646ccc 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ db.sqlite .env.* !.env.example default.profraw +.vscode # Development guidance for Claude Code CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 398c0a4..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,74 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## Project Overview - -HabitTracker API Server is a Swift-based REST API server built with Vapor 4 framework for habit tracking functionality. This is a learning project focused on backend development with Vapor. - -## Technology Stack - -- **Language**: Swift 6.0+ -- **Framework**: Vapor 4.115.0+ -- **Platform**: macOS 13+ -- **Database**: PostgreSQL (production), SQLite (development) -- **Authentication**: JWT -- **Testing**: Swift Testing framework -- **Deployment**: Docker, Heroku support - -## Development Commands - -### Building and Running -```bash -# Resolve dependencies -swift package resolve - -# Run the server in development mode -swift run HabitTrackerAppServer serve --hostname 0.0.0.0 --port 8080 - -# Run tests -swift test - -# Docker commands -docker compose build -docker compose up app -docker compose down -``` - -## Architecture - -The project follows a standard Vapor application structure: - -### Core Files -- `entrypoint.swift` - Application entry point using async/await pattern -- `configure.swift` - Application configuration and middleware setup -- `routes.swift` - Route definitions (currently contains basic hello world routes) - -### Current State -- Basic Vapor server setup is complete -- Simple routes implemented for testing -- Test infrastructure in place using Swift Testing framework -- Docker configuration ready for containerized deployment - -### Planned Architecture (per README) -- JWT authentication system -- RESTful API endpoints for: - - User registration/login/logout - - Categories CRUD - - Habits CRUD - - Habit entries and calendar data -- Protected routes with JWT middleware - -## Environment Configuration - -The application expects a `.env` file with: -- `DATABASE_URL` - Database connection string -- `JWT_SECRET` - JWT signing secret -- `LOG_LEVEL` - Logging level (optional, defaults to debug) - -## Development Notes - -- Project uses Swift 6.0 with ExistentialAny upcoming feature enabled -- Uses VaporTesting for HTTP endpoint testing -- Currently in early development stage - basic server infrastructure is set up but main API features are not yet implemented -- Code follows standard Vapor conventions and patterns \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ae47b74..f513929 100644 --- a/Dockerfile +++ b/Dockerfile @@ -29,11 +29,11 @@ RUN mkdir /staging # N.B.: The static version of jemalloc is incompatible with the static Swift runtime. RUN --mount=type=cache,target=/build/.build \ swift build -c release \ - --product HabitTrackerAppServer \ + --product GrowBitAppServer \ --static-swift-stdlib \ -Xlinker -ljemalloc && \ # Copy main executable to staging area - cp "$(swift build -c release --show-bin-path)/HabitTrackerAppServer" /staging && \ + cp "$(swift build -c release --show-bin-path)/GrowBitAppServer" /staging && \ # Copy resources bundled by SPM to staging area find -L "$(swift build -c release --show-bin-path)" -regex '.*\.resources$' -exec cp -Ra {} /staging \; @@ -48,7 +48,7 @@ RUN cp "/usr/libexec/swift/linux/swift-backtrace-static" ./ RUN [ -d /build/Public ] && { mv /build/Public ./Public && chmod -R a-w ./Public; }; \ [ -d /build/Resources ] && { mv /build/Resources ./Resources && chmod -R a-w ./Resources; }; \ # Make it executable - chmod +x HabitTrackerAppServer; \ + chmod +x GrowBitAppServer; \ # Create the vapor user and group with /vapor home directory useradd --user-group --create-home --system --skel /dev/null --home-dir /vapor vapor @@ -64,5 +64,5 @@ RUN chown -R vapor:vapor /vapor USER vapor:vapor # Start the Vapor service when the image is run, default to listening on 8080 in production environment -ENTRYPOINT ["./HabitTrackerAppServer"] +ENTRYPOINT ["./GrowBitAppServer"] CMD ["serve", "--env", "production", "--hostname", "0.0.0.0", "--port", "8080"] \ No newline at end of file diff --git a/Package.resolved b/Package.resolved index d45f9b1..b2af171 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "b3513f2753a0dd43441713a534385b8dee7bdf3f4d6bd2c6b9d224ad321195f6", + "originHash" : "790b17c32995ac1583b6f3ad5a89c48f9af9e412b7c71c692b3de104b7435fd5", "pins" : [ { "identity" : "async-http-client", @@ -65,12 +65,12 @@ } }, { - "identity" : "habittrackerappshareddto", + "identity" : "growbitshareddto", "kind" : "remoteSourceControl", - "location" : "https://github.com/dmakarau/HabitTrackerAppSharedDTO.git", + "location" : "https://github.com/dmakarau/GrowBitSharedDTO", "state" : { "branch" : "main", - "revision" : "342c7c33c0e9fc30747dbc1d100939dbed3118cd" + "revision" : "8d00b4d406269648f863d4e6cf02e33073445eb7" } }, { diff --git a/Package.swift b/Package.swift index babfdcd..1a99a89 100644 --- a/Package.swift +++ b/Package.swift @@ -2,7 +2,7 @@ import PackageDescription let package = Package( - name: "HabitTrackerAppServer", + name: "GrowBitAppServer", platforms: [ .macOS(.v13) ], @@ -13,18 +13,18 @@ let package = Package( .package(url: "https://github.com/vapor/fluent.git", from: "4.12.0"), .package(url: "https://github.com/vapor/fluent-postgres-driver", from: "2.11.0"), .package(url: "https://github.com/vapor/fluent-sqlite-driver", from: "4.8.0"), - .package(url: "https://github.com/dmakarau/HabitTrackerAppSharedDTO.git", branch: "main"), + .package(url: "https://github.com/dmakarau/GrowBitSharedDTO", branch: "main"), // 🔵 Non-blocking, event-driven networking for Swift. Used for custom executors .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), ], targets: [ .executableTarget( - name: "HabitTrackerAppServer", + name: "GrowBitAppServer", dependencies: [ .product(name: "Vapor", package: "vapor"), .product(name: "JWT", package: "jwt"), - .product(name: "HabitTrackerAppSharedDTO", package: "HabitTrackerAppSharedDTO"), + .product(name: "GrowBitSharedDTO", package: "GrowBitSharedDTO"), .product(name: "Fluent", package: "fluent"), .product(name: "FluentPostgresDriver", package: "fluent-postgres-driver"), .product(name: "FluentSQLiteDriver", package: "fluent-sqlite-driver"), @@ -34,10 +34,11 @@ let package = Package( swiftSettings: swiftSettings ), .testTarget( - name: "HabitTrackerAppServerTests", + name: "GrowBitAppServerTests", dependencies: [ - .target(name: "HabitTrackerAppServer"), + .target(name: "GrowBitAppServer"), .product(name: "VaporTesting", package: "vapor"), + .product(name: "GrowBitSharedDTO", package: "GrowBitSharedDTO"), ], swiftSettings: swiftSettings ) diff --git a/README.md b/README.md index f48f004..9972168 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# HabitTracker API Server +# GrowBit API Server A Swift-based REST API server for habit tracking, built with the Vapor web framework. @@ -12,7 +12,7 @@ A Swift-based REST API server for habit tracking, built with the Vapor web frame - SQLite (development) - **Swift Version**: 6.2+ - **Platform**: macOS 13+ -- **Shared DTOs**: HabitTrackerAppSharedDTO (external package) +- **Shared DTOs**: GrowBitSharedDTO (external package) ## Features @@ -30,6 +30,9 @@ A Swift-based REST API server for habit tracking, built with the Vapor web frame - `POST /api/register` - User registration ✅ - `POST /api/login` - User login ✅ +#### Categories +- `POST /api/categories` - Create new category ✅ + ### Planned Endpoints #### Authentication @@ -38,7 +41,6 @@ A Swift-based REST API server for habit tracking, built with the Vapor web frame #### Categories - `GET /api/categories` - Get all categories -- `POST /api/categories` - Create new category - `PUT /api/categories/:id` - Update category - `DELETE /api/categories/:id` - Delete category @@ -65,8 +67,8 @@ A Swift-based REST API server for habit tracking, built with the Vapor web frame 1. Clone the repository: ```bash -git clone -cd HabitTracker-API-Server +git clone https://github.com/dmakarau/GrowBit-API-Server.git +cd GrowBit-API-Server ``` 2. Resolve dependencies: @@ -89,7 +91,7 @@ openssl rand -base64 32 #### Development Mode ```bash -swift run HabitTrackerAppServer serve --hostname 0.0.0.0 --port 8080 +swift run GrowBitAppServer serve --hostname 0.0.0.0 --port 8080 ``` #### Using Docker @@ -114,27 +116,31 @@ swift test ## Project Structure ``` -HabitTracker-API-Server/ +GrowBit-API-Server/ ├── Package.swift # Swift Package Manager configuration ├── Sources/ -│ └── HabitTrackerAppServer/ +│ └── GrowBitAppServer/ │ ├── entrypoint.swift # Application entry point │ ├── configure.swift # Application configuration │ ├── routes.swift # Route definitions │ ├── Controllers/ # API controllers -│ │ └── UserController.swift # User registration/auth controller +│ │ ├── UserController.swift # User registration/auth controller +│ │ └── HabitsController.swift # Habits and categories controller │ ├── Models/ # Data models │ │ ├── User.swift # User model with validation +│ │ ├── Category.swift # Category model │ │ └── AuthPayload.swift # JWT payload structure │ ├── Extensions/ # Protocol conformances for shared types │ │ ├── RegisterResponseDTO+Extensions.swift # Vapor Content conformance -│ │ └── LoginResponseDTO+Extensions.swift # Vapor Content conformance +│ │ ├── LoginResponseDTO+Extensions.swift # Vapor Content conformance +│ │ └── CategoryResponseDTO+Extensions.swift # Category DTO conformance │ └── Migrations/ # Database migrations -│ └── CreateUsersTableMigration.swift +│ ├── CreateUsersTableMigration.swift +│ └── CreateHabitsCategoryTableMigration.swift ├── Tests/ -│ └── HabitTrackerAppServerTests/ -│ ├── HabitTrackerAppServerTests.swift -│ └── HabitTrackerAppServerLoginTests.swift +│ └── GrowBitAppServerTests/ +│ ├── GrowBitAppServerTests.swift +│ └── GrowBitAppServerLoginTests.swift ├── Public/ # Static files directory ├── Dockerfile # Docker configuration ├── docker-compose.yml # Docker Compose configuration @@ -147,7 +153,7 @@ Create a `.env` file in the root directory with the following variables: ```bash # Database Configuration -DATABASE_URL=postgresql://username:password@localhost:5432/habittracker_db +DATABASE_URL=postgresql://username:password@localhost:5432/growbit_db # JWT Configuration JWT_SECRET=your-super-secret-jwt-key-here @@ -187,16 +193,20 @@ This project serves as a learning experience for backend development with Vapor. - ✅ User login endpoint with JWT token generation - ✅ Password hashing and verification - ✅ Database migration for users table +- ✅ Category model with database migration +- ✅ Category creation endpoint with JWT authentication - ✅ Swift 6.2 concurrency support (@Sendable) - ✅ Shared DTO package integration with @retroactive conformance - ✅ Test suite for authentication endpoints +- ✅ Test suite for category operations ### In Progress - 🔄 User logout endpoint - 🔄 Protected routes with JWT middleware ### Planned Features -- 📋 Categories and Habits CRUD operations +- 📋 Remaining Categories CRUD operations (GET, PUT, DELETE) +- 📋 Habits CRUD operations - 📋 Habit entries and calendar functionality - 📋 JWT token refresh endpoint diff --git a/Sources/HabitTrackerAppServer/Controllers/.gitkeep b/Sources/GrowBitAppServer/Controllers/.gitkeep similarity index 100% rename from Sources/HabitTrackerAppServer/Controllers/.gitkeep rename to Sources/GrowBitAppServer/Controllers/.gitkeep diff --git a/Sources/GrowBitAppServer/Controllers/HabitsController.swift b/Sources/GrowBitAppServer/Controllers/HabitsController.swift new file mode 100644 index 0000000..1c5ece4 --- /dev/null +++ b/Sources/GrowBitAppServer/Controllers/HabitsController.swift @@ -0,0 +1,79 @@ +// +// HabitsController.swift +// GrowBitAppServer +// +// Created by Denis Makarau on 03.10.25. +// + +import Foundation +import Vapor +import GrowBitSharedDTO + +struct HabitsController: RouteCollection { + func boot(routes: any RoutesBuilder) throws { + + // /api/:userId + let api = routes.grouped("api", ":userId") + + // POST: saving a habbit category + // /api/:userId/categories + api.post("categories", use: saveHabitCategory) + } + + @Sendable func saveHabitCategory(req: Request) async throws -> CategoryResponseDTO { + + // get the user id + guard let userId = req.parameters.get("userId", as: UUID.self) else { + throw Abort(.badRequest, reason: "Missing or invalid userId parameter") // HTTP 400 + } + + // Decode request as a simple dictionary to get name and colorCode + let requestData = try req.content.decode([String: String].self) + guard let name = requestData["name"], + let colorCode = requestData["colorCode"] else { + throw Abort(.badRequest, reason: "Missing required fields: name and colorCode") // HTTP 400 + } + + // Validate empty name + guard !name.isEmpty else { + throw Abort(.badRequest, reason: "Category name cannot be empty") // HTTP 400 + } + + // Validate color code format (#RRGGBB) + let colorCodePattern = #"^#([A-Fa-f0-9]{6})$"# + guard colorCode.range(of: colorCodePattern, options: .regularExpression) != nil else { + throw Abort(.badRequest, reason: "Color code should be in format #RRGGBB") // HTTP 400 + } + + // Check for duplicate category name for this user (case-insensitive) + let existingCategories = try await Category.query(on: req.db) + .filter(\.$user.$id, .equal, userId) + .all() + + let existingCategory = existingCategories.first(where: { $0.name.lowercased() == name.lowercased() }) + + if existingCategory != nil { + throw Abort(.conflict, reason: "A category with this name already exists") // HTTP 409 + } + + let habitCategory = Category( + name: name, + colorCode: colorCode, + userId: userId + ) + try await habitCategory.save(on: req.db) + + // After saving, ensure the ID is assigned + guard habitCategory.id != nil else { + throw Abort(.internalServerError, reason: "Failed to get ID after saving category") // HTTP 500 + } + + // DTO for the response + guard let categoryResponseDTO = CategoryResponseDTO(habitCategory) else { + throw Abort(.internalServerError, reason: "Failed to create response DTO") // HTTP 500 + } + + return categoryResponseDTO + } + +} diff --git a/Sources/HabitTrackerAppServer/Controllers/UserController.swift b/Sources/GrowBitAppServer/Controllers/UserController.swift similarity index 97% rename from Sources/HabitTrackerAppServer/Controllers/UserController.swift rename to Sources/GrowBitAppServer/Controllers/UserController.swift index 51bb1ca..0a88040 100644 --- a/Sources/HabitTrackerAppServer/Controllers/UserController.swift +++ b/Sources/GrowBitAppServer/Controllers/UserController.swift @@ -1,6 +1,6 @@ // // UserController.swift -// HabitTrackerAppServer +// GrowBitAppServer // // Created by Denis Makarau on 24.09.25. // @@ -8,7 +8,7 @@ import Foundation import Vapor import Fluent -import HabitTrackerAppSharedDTO +import GrowBitSharedDTO struct UserController: RouteCollection { func boot(routes: any Vapor.RoutesBuilder) throws { diff --git a/Sources/GrowBitAppServer/Extensions/HabitsCategoryResponseDTO+Extensions.swift b/Sources/GrowBitAppServer/Extensions/HabitsCategoryResponseDTO+Extensions.swift new file mode 100644 index 0000000..117d5dc --- /dev/null +++ b/Sources/GrowBitAppServer/Extensions/HabitsCategoryResponseDTO+Extensions.swift @@ -0,0 +1,22 @@ +// +// HabitsCategoryResponseDTO+Extensions.swift +// GrowBitAppServer +// +// Created by Denis Makarau on 06.10.25. +// + +import Foundation +import GrowBitSharedDTO +import Vapor + +extension CategoryResponseDTO: @retroactive RequestDecodable {} +extension CategoryResponseDTO: @retroactive ResponseEncodable {} +extension CategoryResponseDTO: @retroactive AsyncRequestDecodable {} +extension CategoryResponseDTO: @retroactive AsyncResponseEncodable {} +extension CategoryResponseDTO: @retroactive Content { + init?(_ category: Category) { + guard let id = category.id else { return nil } + + self.init(id: id, name: category.name, colorCode: category.colorCode) + } +} diff --git a/Sources/HabitTrackerAppServer/Extensions/LoginResponseDTO+Extensions.swift b/Sources/GrowBitAppServer/Extensions/LoginResponseDTO+Extensions.swift similarity index 87% rename from Sources/HabitTrackerAppServer/Extensions/LoginResponseDTO+Extensions.swift rename to Sources/GrowBitAppServer/Extensions/LoginResponseDTO+Extensions.swift index b063610..6431ea6 100644 --- a/Sources/HabitTrackerAppServer/Extensions/LoginResponseDTO+Extensions.swift +++ b/Sources/GrowBitAppServer/Extensions/LoginResponseDTO+Extensions.swift @@ -1,12 +1,12 @@ // // RegisterResponseDTO.swift -// HabitTrackerAppServer +// GrowBitAppServer // // Created by Denis Makarau on 03.10.25. // import Foundation -import HabitTrackerAppSharedDTO +import GrowBitSharedDTO import Vapor extension LoginResponseDTO: @retroactive RequestDecodable {} diff --git a/Sources/HabitTrackerAppServer/Extensions/RegisterResponseDTO+Extensions.swift b/Sources/GrowBitAppServer/Extensions/RegisterResponseDTO+Extensions.swift similarity index 88% rename from Sources/HabitTrackerAppServer/Extensions/RegisterResponseDTO+Extensions.swift rename to Sources/GrowBitAppServer/Extensions/RegisterResponseDTO+Extensions.swift index b8dd224..fb03cf5 100644 --- a/Sources/HabitTrackerAppServer/Extensions/RegisterResponseDTO+Extensions.swift +++ b/Sources/GrowBitAppServer/Extensions/RegisterResponseDTO+Extensions.swift @@ -1,13 +1,13 @@ // // RegisterResponseDTO.swift -// HabitTrackerAppServer +// GrowBitAppServer // // Created by Denis Makarau on 03.10.25. // import Foundation import Vapor -import HabitTrackerAppSharedDTO +import GrowBitSharedDTO extension RegisterResponseDTO: @retroactive RequestDecodable {} extension RegisterResponseDTO: @retroactive ResponseEncodable {} diff --git a/Sources/GrowBitAppServer/Migrations/CreateHabitsCategoryTableMigration.swift b/Sources/GrowBitAppServer/Migrations/CreateHabitsCategoryTableMigration.swift new file mode 100644 index 0000000..8483321 --- /dev/null +++ b/Sources/GrowBitAppServer/Migrations/CreateHabitsCategoryTableMigration.swift @@ -0,0 +1,27 @@ +// +// CreateHabitsCategoryTableMigration.swift +// GrowBitAppServer +// +// Created by Denis Makarau on 03.10.25. +// + +import Foundation +import Fluent + +struct CreateHabitsCategoryTableMigration: AsyncMigration { + + func prepare(on database: any Database) async throws { + try await database.schema("habits_categories") + .id() + .field("name", .string, .required) + .field("color_code", .string, .required) + .field("user_id", .uuid, .required, .references("users", "id")) + .create() + } + + func revert(on database: any Database) async throws { + try await database.schema("habits_categories") + .delete() + } +} + diff --git a/Sources/HabitTrackerAppServer/Migrations/CreateUsersTableMigration.swift b/Sources/GrowBitAppServer/Migrations/CreateUsersTableMigration.swift similarity index 95% rename from Sources/HabitTrackerAppServer/Migrations/CreateUsersTableMigration.swift rename to Sources/GrowBitAppServer/Migrations/CreateUsersTableMigration.swift index eeecc94..7527ff8 100644 --- a/Sources/HabitTrackerAppServer/Migrations/CreateUsersTableMigration.swift +++ b/Sources/GrowBitAppServer/Migrations/CreateUsersTableMigration.swift @@ -1,6 +1,6 @@ // // CreateUsersTableMigration.swift.swift -// HabitTrackerAppServer +// GrowBitAppServer // // Created by Denis Makarau on 24.09.25. // diff --git a/Sources/HabitTrackerAppServer/Models/AuthPayload.swift b/Sources/GrowBitAppServer/Models/AuthPayload.swift similarity index 95% rename from Sources/HabitTrackerAppServer/Models/AuthPayload.swift rename to Sources/GrowBitAppServer/Models/AuthPayload.swift index cf78c91..02761e6 100644 --- a/Sources/HabitTrackerAppServer/Models/AuthPayload.swift +++ b/Sources/GrowBitAppServer/Models/AuthPayload.swift @@ -1,6 +1,6 @@ // // AuthPayload.swift -// HabitTrackerAppServer +// GrowBitAppServer // // Created by Denis Makarau on 25.09.25. // diff --git a/Sources/GrowBitAppServer/Models/Category.swift b/Sources/GrowBitAppServer/Models/Category.swift new file mode 100644 index 0000000..a4a7696 --- /dev/null +++ b/Sources/GrowBitAppServer/Models/Category.swift @@ -0,0 +1,49 @@ +// Category.swift +// GrowBitAppServer +// +// Created by Denis Makarau on 03.10.25. +// + +import Foundation +import Fluent +import Vapor + +final class Category: Model, Validatable, Content, @unchecked Sendable { + static let schema = "habits_categories" + + @ID(key: .id) + var id: UUID? + + @Field(key: "name") + var name: String + + @Field(key: "color_code") + var colorCode: String + + @Parent(key: "user_id") + var user: User + + init() {} + + init(id: UUID? = nil, name: String, colorCode: String, userId: UUID) { + self.id = id + self.name = name + self.colorCode = colorCode + self.$user.id = userId + } + + static func validations(_ validations: inout Validations) { + validations.add("name", as: String.self, is: !.empty, customFailureDescription: "Category name cannot be empty") + validations.add("colorCode", as: String.self, is: !.empty, customFailureDescription: "Color coe cannot be empty") + validations.add("color_code", as: String.self, is: .pattern(#"^#([A-Fa-f0-9]{6})$"#), customFailureDescription: "Color code should be in format #RRGGBB") + } + + + + + + + + +} + diff --git a/Sources/HabitTrackerAppServer/Models/User.swift b/Sources/GrowBitAppServer/Models/User.swift similarity index 97% rename from Sources/HabitTrackerAppServer/Models/User.swift rename to Sources/GrowBitAppServer/Models/User.swift index 57d4e8a..af45be8 100644 --- a/Sources/HabitTrackerAppServer/Models/User.swift +++ b/Sources/GrowBitAppServer/Models/User.swift @@ -1,6 +1,6 @@ // // User.swift -// HabitTrackerAppServer +// GrowBitAppServer // // Created by Denis Makarau on 24.09.25. // diff --git a/Sources/HabitTrackerAppServer/configure.swift b/Sources/GrowBitAppServer/configure.swift similarity index 94% rename from Sources/HabitTrackerAppServer/configure.swift rename to Sources/GrowBitAppServer/configure.swift index 2e8f105..27b1ab7 100644 --- a/Sources/HabitTrackerAppServer/configure.swift +++ b/Sources/GrowBitAppServer/configure.swift @@ -30,6 +30,7 @@ public func configure(_ app: Application) async throws { } app.migrations.add(CreateUsersTableMigration()) + app.migrations.add(CreateHabitsCategoryTableMigration()) // Auto-migrate in testing environment (creates tables automatically) if app.environment == .testing { @@ -37,6 +38,7 @@ public func configure(_ app: Application) async throws { } try app.register(collection: UserController()) + try app.register(collection: HabitsController()) // JWT configuration: use environment variable or default for testing diff --git a/Sources/HabitTrackerAppServer/entrypoint.swift b/Sources/GrowBitAppServer/entrypoint.swift similarity index 100% rename from Sources/HabitTrackerAppServer/entrypoint.swift rename to Sources/GrowBitAppServer/entrypoint.swift diff --git a/Sources/HabitTrackerAppServer/routes.swift b/Sources/GrowBitAppServer/routes.swift similarity index 100% rename from Sources/HabitTrackerAppServer/routes.swift rename to Sources/GrowBitAppServer/routes.swift diff --git a/Tests/HabitTrackerAppServerTests/HabitTrackerAppServerLoginTests.swift b/Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift similarity index 96% rename from Tests/HabitTrackerAppServerTests/HabitTrackerAppServerLoginTests.swift rename to Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift index 50869f1..d7988fa 100644 --- a/Tests/HabitTrackerAppServerTests/HabitTrackerAppServerLoginTests.swift +++ b/Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift @@ -1,18 +1,19 @@ // -// HabitTrackerAppServerLoginTests.swift -// HabitTrackerAppServer +// GrowBitAppServerLoginTests.swift +// GrowBitAppServer // // Created by Denis Makarau on 26.09.25. // import Foundation -@testable import HabitTrackerAppServer +@testable import GrowBitAppServer +import GrowBitSharedDTO import VaporTesting import Testing import HabitTrackerAppSharedDTO @Suite("App Login Tests") -struct HabitTrackerAppServerLoginTests { +struct GrowBitAppServerLoginTests { private func createUser(in app: Application, username: String = "testuser") async throws { let requestBody = User(username: username, password: "password") diff --git a/Tests/HabitTrackerAppServerTests/HabitTrackerAppServerRegistrationTests.swift b/Tests/GrowBitAppServerTests/GrowBitAppServerRegistrationTests.swift similarity index 95% rename from Tests/HabitTrackerAppServerTests/HabitTrackerAppServerRegistrationTests.swift rename to Tests/GrowBitAppServerTests/GrowBitAppServerRegistrationTests.swift index 1e1f1c6..2607cdb 100644 --- a/Tests/HabitTrackerAppServerTests/HabitTrackerAppServerRegistrationTests.swift +++ b/Tests/GrowBitAppServerTests/GrowBitAppServerRegistrationTests.swift @@ -1,10 +1,10 @@ -@testable import HabitTrackerAppServer +@testable import GrowBitAppServer import VaporTesting -import HabitTrackerAppSharedDTO +import GrowBitSharedDTO import Testing @Suite("App Registration Tests") -struct HabitTrackerAppServerRegistrationTests { +struct GrowBitAppServerRegistrationTests { @Test("Test Hello World Route") func helloWorld() async throws { try await withApp(configure: configure) { app in diff --git a/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift b/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift new file mode 100644 index 0000000..894389b --- /dev/null +++ b/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift @@ -0,0 +1,240 @@ +// +// GrowBitAppServerSavingCategoryTests.swift +// GrowBitAppServer +// +// Created by Denis Makarau on 06.10.25. +// + +@testable import GrowBitAppServer +import VaporTesting +import GrowBitSharedDTO +import Testing +import Fluent + +@Suite("Category Creation Tests") +struct GrowBitAppServerSavingCategoryTests { + + @Test("Category creation - Success") + func categoryCreationSuccess() async throws { + try await withApp(configure: configure) { app in + // First create a user using the same pattern as other tests + let userRequestBody = User(username: "testuser", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode(RegisterResponseDTO.self) + #expect(response.error == false) + } + + // Retrieve the created user from the database + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "testuser") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Create a simple request object with just name and colorCode (no id or userId) + let requestBody = [ + "name": "test category", + "colorCode": "#FFFFFF" + ] + + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(requestBody) + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode(CategoryResponseDTO.self) + #expect(response.name == "test category") + #expect(response.colorCode == "#FFFFFF") + } + } + } + + @Test("Category creation - Fail - Missing name") + func categoryCreationFailMissingName() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "testuser2", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "testuser2") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Request body missing name + let requestBody = [ + "colorCode": "#FFFFFF" + ] + + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(requestBody) + } afterResponse: { res in + #expect(res.status == .badRequest) + #expect(res.body.string.contains("Missing required fields")) + } + } + } + + @Test("Category creation - Fail - Missing colorCode") + func categoryCreationFailMissingColorCode() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "testuser3", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "testuser3") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Request body missing colorCode + let requestBody = [ + "name": "test category" + ] + + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(requestBody) + } afterResponse: { res in + #expect(res.status == .badRequest) + #expect(res.body.string.contains("Missing required fields")) + } + } + } + + @Test("Category creation - Fail - Invalid userId") + func categoryCreationFailInvalidUserId() async throws { + try await withApp(configure: configure) { app in + let requestBody = [ + "name": "test category", + "colorCode": "#FFFFFF" + ] + + try await app.testing().test(.POST, "/api/invalid-uuid/categories") { req in + try req.content.encode(requestBody) + } afterResponse: { res in + #expect(res.status == .badRequest) + } + } + } + + @Test("Category creation - Fail - Empty name") + func categoryCreationFailEmptyName() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "testuser4", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "testuser4") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Request body with empty name + let requestBody = [ + "name": "", + "colorCode": "#FFFFFF" + ] + + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(requestBody) + } afterResponse: { res in + #expect(res.status == .badRequest) + } + } + } + + @Test("Category creation - Fail - Invalid color code format") + func categoryCreationFailInvalidColorCode() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "testuser5", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "testuser5") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Request body with invalid color code (missing #) + let requestBody = [ + "name": "test category", + "colorCode": "FFFFFF" + ] + + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(requestBody) + } afterResponse: { res in + #expect(res.status == .badRequest) + } + } + } + + @Test("Category creation - Success - Valid color codes") + func categoryCreationSuccessVariousColors() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "testuser6", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "testuser6") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Test various valid color codes + let validColors = ["#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#abcdef", "#123456"] + + for (index, color) in validColors.enumerated() { + let requestBody = [ + "name": "category \(index)", + "colorCode": color + ] + + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(requestBody) + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode(CategoryResponseDTO.self) + #expect(response.colorCode.uppercased() == color.uppercased()) + } + } + } + } +} + +enum TestError: Error { + case userCreationFailed +} diff --git a/docker-compose.yml b/docker-compose.yml index 67229a6..8e64dd9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -18,7 +18,7 @@ x-shared_environment: &shared_environment services: app: - image: habit-tracker-app-server:latest + image: growbit-app-server:latest build: context: . environment: