diff --git a/.gitignore b/.gitignore index e646ccc..e6f268d 100644 --- a/.gitignore +++ b/.gitignore @@ -11,6 +11,7 @@ db.sqlite !.env.example default.profraw .vscode +Package.resolved # Development guidance for Claude Code -CLAUDE.md \ No newline at end of file +CLAUDE.md diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index b2af171..0000000 --- a/Package.resolved +++ /dev/null @@ -1,366 +0,0 @@ -{ - "originHash" : "790b17c32995ac1583b6f3ad5a89c48f9af9e412b7c71c692b3de104b7435fd5", - "pins" : [ - { - "identity" : "async-http-client", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/async-http-client.git", - "state" : { - "revision" : "7dc119c7edf3c23f52638faadb89182861dee853", - "version" : "1.28.0" - } - }, - { - "identity" : "async-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/async-kit.git", - "state" : { - "revision" : "6f3615ccf2ac3c2ae0c8087d527546e9544a43dd", - "version" : "1.21.0" - } - }, - { - "identity" : "console-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/console-kit.git", - "state" : { - "revision" : "742f624a998cba2a9e653d9b1e91ad3f3a5dff6b", - "version" : "4.15.2" - } - }, - { - "identity" : "fluent", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/fluent.git", - "state" : { - "revision" : "2fe9e36daf4bdb5edcf193e0d0806ba2074d2864", - "version" : "4.13.0" - } - }, - { - "identity" : "fluent-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/fluent-kit.git", - "state" : { - "revision" : "8baacd7e8f7ebf68886c496b43bbe6cdcc5b57e0", - "version" : "1.52.2" - } - }, - { - "identity" : "fluent-postgres-driver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/fluent-postgres-driver", - "state" : { - "revision" : "cd47a7042a529735e401bdfaa070823d151f7f94", - "version" : "2.11.0" - } - }, - { - "identity" : "fluent-sqlite-driver", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/fluent-sqlite-driver", - "state" : { - "revision" : "73529a63ab11c7fe87da17b5a67a1b1f58c020f8", - "version" : "4.8.1" - } - }, - { - "identity" : "growbitshareddto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/dmakarau/GrowBitSharedDTO", - "state" : { - "branch" : "main", - "revision" : "8d00b4d406269648f863d4e6cf02e33073445eb7" - } - }, - { - "identity" : "jwt", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/jwt.git", - "state" : { - "revision" : "af1c59762d70d1065ddbc0d7902ea9b3dacd1a26", - "version" : "5.1.2" - } - }, - { - "identity" : "jwt-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/jwt-kit.git", - "state" : { - "revision" : "2033b3e661238dda3d30e36a2d40987499d987de", - "version" : "5.2.0" - } - }, - { - "identity" : "multipart-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/multipart-kit.git", - "state" : { - "revision" : "3498e60218e6003894ff95192d756e238c01f44e", - "version" : "4.7.1" - } - }, - { - "identity" : "postgres-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/postgres-kit.git", - "state" : { - "revision" : "d2fd3172c2e318bd292a4c1297e4c65a418cf6f3", - "version" : "2.14.1" - } - }, - { - "identity" : "postgres-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/postgres-nio.git", - "state" : { - "revision" : "8ee6118c03501196be183b0938d2ec4478c18954", - "version" : "1.27.0" - } - }, - { - "identity" : "routing-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/routing-kit.git", - "state" : { - "revision" : "93f7222c8e195cbad39fafb5a0e4cc85a8def7ea", - "version" : "4.9.2" - } - }, - { - "identity" : "sql-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/sql-kit.git", - "state" : { - "revision" : "1a9ab0523fb742d9629558cede64290165c4285b", - "version" : "3.33.2" - } - }, - { - "identity" : "sqlite-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/sqlite-kit.git", - "state" : { - "revision" : "f35a863ecc2da5d563b836a9a696b148b0f4169f", - "version" : "4.5.2" - } - }, - { - "identity" : "sqlite-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/sqlite-nio.git", - "state" : { - "revision" : "a4c62fa1d99db69bf96d48f5b903eca1427f4a0e", - "version" : "1.12.0" - } - }, - { - "identity" : "swift-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-algorithms.git", - "state" : { - "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-asn1", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-asn1.git", - "state" : { - "revision" : "f70225981241859eb4aa1a18a75531d26637c8cc", - "version" : "1.4.0" - } - }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", - "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" - } - }, - { - "identity" : "swift-atomics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-atomics.git", - "state" : { - "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-certificates", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-certificates.git", - "state" : { - "revision" : "4b092f15164144c24554e0a75e080a960c5190a6", - "version" : "1.14.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-crypto", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-crypto.git", - "state" : { - "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", - "version" : "3.15.1" - } - }, - { - "identity" : "swift-distributed-tracing", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-distributed-tracing.git", - "state" : { - "revision" : "6600888f4cb5bbf1bcac51000f60b2cbd224c91b", - "version" : "1.3.0" - } - }, - { - "identity" : "swift-http-structured-headers", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-structured-headers.git", - "state" : { - "revision" : "1625f271afb04375bf48737a5572613248d0e7a0", - "version" : "1.4.0" - } - }, - { - "identity" : "swift-http-types", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types.git", - "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" - } - }, - { - "identity" : "swift-metrics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-metrics.git", - "state" : { - "revision" : "0743a9364382629da3bf5677b46a2c4b1ce5d2a6", - "version" : "2.7.1" - } - }, - { - "identity" : "swift-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", - "state" : { - "revision" : "a18bddb0acf7a40d982b2f128ce73ce4ee31f352", - "version" : "2.86.2" - } - }, - { - "identity" : "swift-nio-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-extras.git", - "state" : { - "revision" : "a55c3dd3a81d035af8a20ce5718889c0dcab073d", - "version" : "1.29.0" - } - }, - { - "identity" : "swift-nio-http2", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-http2.git", - "state" : { - "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", - "version" : "1.38.0" - } - }, - { - "identity" : "swift-nio-ssl", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssl.git", - "state" : { - "revision" : "b2b043a8810ab6d51b3ff4df17f057d87ef1ec7c", - "version" : "2.34.1" - } - }, - { - "identity" : "swift-nio-transport-services", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-transport-services.git", - "state" : { - "revision" : "e645014baea2ec1c2db564410c51a656cf47c923", - "version" : "1.25.1" - } - }, - { - "identity" : "swift-numerics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-numerics.git", - "state" : { - "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", - "version" : "1.1.1" - } - }, - { - "identity" : "swift-service-context", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-service-context.git", - "state" : { - "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6", - "version" : "1.2.1" - } - }, - { - "identity" : "swift-service-lifecycle", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swift-server/swift-service-lifecycle.git", - "state" : { - "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", - "version" : "2.8.0" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", - "state" : { - "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version" : "1.6.3" - } - }, - { - "identity" : "vapor", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/vapor.git", - "state" : { - "revision" : "773ea6a63595ae4f6bc46a366d78769d4cb8b08c", - "version" : "4.117.0" - } - }, - { - "identity" : "websocket-kit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/vapor/websocket-kit.git", - "state" : { - "revision" : "8666c92dbbb3c8eefc8008c9c8dcf50bfd302167", - "version" : "2.16.1" - } - } - ], - "version" : 3 -} diff --git a/README.md b/README.md index 9972168..5676381 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,9 @@ A Swift-based REST API server for habit tracking, built with the Vapor web frame - `POST /api/login` - User login ✅ #### Categories -- `POST /api/categories` - Create new category ✅ +- `POST /api/:userId/categories` - Create new category ✅ +- `GET /api/:userId/categories` - Get all categories for user ✅ +- `DELETE /api/:userId/categories/:categoryId` - Delete category ✅ ### Planned Endpoints @@ -40,9 +42,7 @@ A Swift-based REST API server for habit tracking, built with the Vapor web frame - `POST /api/logout` - User logout #### Categories -- `GET /api/categories` - Get all categories -- `PUT /api/categories/:id` - Update category -- `DELETE /api/categories/:id` - Delete category +- `PUT /api/:userId/categories/:id` - Update category #### Habits - `GET /api/habits` - Get all habits @@ -194,18 +194,22 @@ This project serves as a learning experience for backend development with Vapor. - ✅ Password hashing and verification - ✅ Database migration for users table - ✅ Category model with database migration -- ✅ Category creation endpoint with JWT authentication +- ✅ Categories CRUD operations (Create, Read, Delete) +- ✅ Category validation (color code format, empty names, duplicate names) +- ✅ Color code normalization (RRGGBB format with # prefix) +- ✅ User ownership verification for category operations - ✅ Swift 6.2 concurrency support (@Sendable) - ✅ Shared DTO package integration with @retroactive conformance - ✅ Test suite for authentication endpoints -- ✅ Test suite for category operations +- ✅ Test suite for category operations (create, fetch, delete) +- ✅ Comprehensive error handling with proper HTTP status codes ### In Progress - 🔄 User logout endpoint - 🔄 Protected routes with JWT middleware ### Planned Features -- 📋 Remaining Categories CRUD operations (GET, PUT, DELETE) +- 📋 Category UPDATE operation - 📋 Habits CRUD operations - 📋 Habit entries and calendar functionality - 📋 JWT token refresh endpoint diff --git a/Sources/GrowBitAppServer/Controllers/HabitsController.swift b/Sources/GrowBitAppServer/Controllers/HabitsController.swift index 1c5ece4..af4b408 100644 --- a/Sources/GrowBitAppServer/Controllers/HabitsController.swift +++ b/Sources/GrowBitAppServer/Controllers/HabitsController.swift @@ -18,6 +18,14 @@ struct HabitsController: RouteCollection { // POST: saving a habbit category // /api/:userId/categories api.post("categories", use: saveHabitCategory) + + // GET: getting all categories for a user + // /api/:userId/categories + api.get("categories", use: getAllCategoriesForUser) + + // DELETE: deleting a category + // /api/:userId/categories/:categoryId + api.delete("categories", ":categoryId", use: deleteCategory) } @Sendable func saveHabitCategory(req: Request) async throws -> CategoryResponseDTO { @@ -39,12 +47,15 @@ struct HabitsController: RouteCollection { throw Abort(.badRequest, reason: "Category name cannot be empty") // HTTP 400 } - // Validate color code format (#RRGGBB) - let colorCodePattern = #"^#([A-Fa-f0-9]{6})$"# + // Validate color code format (RRGGBB or #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 + throw Abort(.badRequest, reason: "Color code should be in format RRGGBB or #RRGGBB") // HTTP 400 } + // Normalize color code to always include # + let normalizedColorCode = colorCode.hasPrefix("#") ? colorCode : "#\(colorCode)" + // Check for duplicate category name for this user (case-insensitive) let existingCategories = try await Category.query(on: req.db) .filter(\.$user.$id, .equal, userId) @@ -58,7 +69,7 @@ struct HabitsController: RouteCollection { let habitCategory = Category( name: name, - colorCode: colorCode, + colorCode: normalizedColorCode, userId: userId ) try await habitCategory.save(on: req.db) @@ -75,5 +86,43 @@ struct HabitsController: RouteCollection { return categoryResponseDTO } + + @Sendable func getAllCategoriesForUser(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 + } + + // get all categories + return try await Category.query(on: req.db) + .filter(\.$user.$id, .equal, userId) + .all() + .compactMap(CategoryResponseDTO.init) + } + + @Sendable func deleteCategory(req: Request) async throws -> CategoryResponseDTO { + // get the user id + guard let userId = req.parameters.get("userId", as: UUID.self), + let categoryId = req.parameters.get("categoryId", as: UUID.self) + else { + throw Abort(.badRequest, reason: "Missing or invalid userId parameter") // HTTP 400 + } + + guard let category = try await Category.query(on: req.db) + .filter(\.$user.$id, .equal, userId) + .filter(\.$id, .equal, categoryId) + .first() else { + throw Abort(.notFound, reason: "Category not found for this user") // HTTP 404 + } + try await category.delete(on: req.db) + + guard let categoryDTO = CategoryResponseDTO(category) else { + throw Abort(.internalServerError, reason: "Failed to create response DTO") // HTTP 500 + } + + return categoryDTO + } + } diff --git a/Sources/GrowBitAppServer/Extensions/HabitsCategoryResponseDTO+Extensions.swift b/Sources/GrowBitAppServer/Extensions/CategoryResponseDTO+Extensions.swift similarity index 93% rename from Sources/GrowBitAppServer/Extensions/HabitsCategoryResponseDTO+Extensions.swift rename to Sources/GrowBitAppServer/Extensions/CategoryResponseDTO+Extensions.swift index 117d5dc..f99f2ce 100644 --- a/Sources/GrowBitAppServer/Extensions/HabitsCategoryResponseDTO+Extensions.swift +++ b/Sources/GrowBitAppServer/Extensions/CategoryResponseDTO+Extensions.swift @@ -1,5 +1,5 @@ // -// HabitsCategoryResponseDTO+Extensions.swift +// CategoryResponseDTO+Extensions.swift // GrowBitAppServer // // Created by Denis Makarau on 06.10.25. diff --git a/Sources/GrowBitAppServer/Models/Category.swift b/Sources/GrowBitAppServer/Models/Category.swift index a4a7696..f5da379 100644 --- a/Sources/GrowBitAppServer/Models/Category.swift +++ b/Sources/GrowBitAppServer/Models/Category.swift @@ -34,8 +34,8 @@ final class Category: Model, Validatable, Content, @unchecked Sendable { 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") + validations.add("colorCode", as: String.self, is: !.empty, customFailureDescription: "Color code 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 or #RRGGBB") } diff --git a/Tests/GrowBitAppServerTests/GrowBitAppServerDeleteCategoryTests.swift b/Tests/GrowBitAppServerTests/GrowBitAppServerDeleteCategoryTests.swift new file mode 100644 index 0000000..a975bb1 --- /dev/null +++ b/Tests/GrowBitAppServerTests/GrowBitAppServerDeleteCategoryTests.swift @@ -0,0 +1,318 @@ +// +// GrowBitAppServerDeleteCategoryTests.swift +// GrowBitAppServer +// +// Created by Denis Makarau on 08.10.25. +// + +@testable import GrowBitAppServer +import VaporTesting +import GrowBitSharedDTO +import Testing +import Fluent + +@Suite("Category Deletion Tests") +struct GrowBitAppServerDeleteCategoryTests { + + @Test("Delete category - Success") + func deleteCategorySuccess() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "deleteuser1", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + // Retrieve the created user + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "deleteuser1") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Create a category + let categoryRequestBody = [ + "name": "Test Category", + "colorCode": "#FF0000" + ] + + var categoryId: UUID? + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(categoryRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode(CategoryResponseDTO.self) + categoryId = response.id + #expect(response.name == "Test Category") + } + + guard let unwrappedCategoryId = categoryId else { + throw TestError.userCreationFailed + } + + // Delete the category + try await app.testing().test(.DELETE, "/api/\(userId.uuidString)/categories/\(unwrappedCategoryId.uuidString)") { req in + // No body needed for DELETE request + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode(CategoryResponseDTO.self) + #expect(response.id == unwrappedCategoryId) + #expect(response.name == "Test Category") + #expect(response.colorCode == "#FF0000") + } + + // Verify the category was actually deleted from the database + let deletedCategory = try await Category.query(on: app.db) + .filter(\.$id == unwrappedCategoryId) + .first() + #expect(deletedCategory == nil) + } + } + + @Test("Delete category - Verify category is removed from list") + func deleteCategoryVerifyRemoval() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "deleteuser2", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + // Retrieve the created user + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "deleteuser2") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Create multiple categories + let categories = [ + ["name": "Work", "colorCode": "#FF0000"], + ["name": "Personal", "colorCode": "#00FF00"], + ["name": "Health", "colorCode": "#0000FF"] + ] + + var categoryIds: [UUID] = [] + for category in categories { + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(category) + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode(CategoryResponseDTO.self) + categoryIds.append(response.id) + } + } + + #expect(categoryIds.count == 3) + + // Delete the category + try await app.testing().test(.DELETE, "/api/\(userId.uuidString)/categories/\(categoryIds[1].uuidString)") { req in + // No body needed for DELETE request + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode(CategoryResponseDTO.self) + #expect(response.id == (categoryIds[1])) + #expect(response.name == "Personal") + #expect(response.colorCode == "#00FF00") + } + + // Verify the category was actually deleted from the database + let deletedCategory = try await Category.query(on: app.db) + .filter(\.$id == categoryIds[1]) + .first() + #expect(deletedCategory == nil) + + // Verify the category still exists for user1 + var category = try await Category.query(on: app.db) + .filter(\.$id == categoryIds[0]) + .first() + #expect(category != nil) + + // Verify the third category still exists for user1 + category = try await Category.query(on: app.db) + .filter(\.$id == categoryIds[2]) + .first() + #expect(category != nil) + } + + } + + @Test("Delete category - Fail - Invalid categoryId") + func deleteCategoryInvalidCategoryId() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "deleteuser3", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + // Retrieve the created user + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "deleteuser3") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Try to delete with invalid category UUID format + try await app.testing().test(.DELETE, "/api/\(userId.uuidString)/categories/invalid-uuid") { req in + // No body needed for DELETE request + } afterResponse: { res in + #expect(res.status == .badRequest) + } + } + } + + @Test("Delete category - Fail - Non-existent categoryId") + func deleteCategoryNonExistentCategoryId() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "deleteuser4", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + // Retrieve the created user + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "deleteuser4") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Try to delete a category that doesn't exist (valid UUID format but doesn't exist) + let nonExistentId = UUID() + try await app.testing().test(.DELETE, "/api/\(userId.uuidString)/categories/\(nonExistentId.uuidString)") { req in + // No body needed for DELETE request + } afterResponse: { res in + #expect(res.status == .notFound) + #expect(res.body.string.contains("Category not found")) + } + } + } + + @Test("Delete category - Fail - Invalid userId") + func deleteCategoryInvalidUserId() async throws { + try await withApp(configure: configure) { app in + // Create a user and category + let userRequestBody = User(username: "deleteuser5", 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 == "deleteuser5") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Create a category + let categoryRequestBody = [ + "name": "Test Category", + "colorCode": "#FF0000" + ] + + var categoryId: UUID? + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(categoryRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode(CategoryResponseDTO.self) + categoryId = response.id + } + + guard let unwrappedCategoryId = categoryId else { + throw TestError.userCreationFailed + } + + // Try to delete with invalid user UUID format + try await app.testing().test(.DELETE, "/api/invalid-uuid/categories/\(unwrappedCategoryId.uuidString)") { req in + // No body needed for DELETE request + } afterResponse: { res in + #expect(res.status == .badRequest) + } + } + } + + @Test("Delete category - User isolation test") + func deleteCategoryUserIsolation() async throws { + try await withApp(configure: configure) { app in + // Create two users + let userRequestBody1 = User(username: "deleteuser6a", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody1) + } afterResponse: { res in + #expect(res.status == .ok) + } + + let userRequestBody2 = User(username: "deleteuser6b", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody2) + } afterResponse: { res in + #expect(res.status == .ok) + } + + // Retrieve both users + guard let createdUser1 = try await User.query(on: app.db) + .filter(\.$username == "deleteuser6a") + .first(), + let userId1 = createdUser1.id else { + throw TestError.userCreationFailed + } + + guard let createdUser2 = try await User.query(on: app.db) + .filter(\.$username == "deleteuser6b") + .first(), + let userId2 = createdUser2.id else { + throw TestError.userCreationFailed + } + + // Create a category for user1 + let categoryRequestBody = [ + "name": "User1 Category", + "colorCode": "#FF0000" + ] + + var user1CategoryId: UUID? + try await app.testing().test(.POST, "/api/\(userId1.uuidString)/categories") { req in + try req.content.encode(categoryRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode(CategoryResponseDTO.self) + user1CategoryId = response.id + } + + guard let unwrappedUser1CategoryId = user1CategoryId else { + throw TestError.userCreationFailed + } + + // Try to delete user1's category using user2's userId + try await app.testing().test(.DELETE, "/api/\(userId2.uuidString)/categories/\(unwrappedUser1CategoryId.uuidString)") { req in + // No body needed for DELETE request + } afterResponse: { res in + #expect(res.status == .notFound) + #expect(res.body.string.contains("Category not found")) + } + + // Verify the category still exists for user1 + let category = try await Category.query(on: app.db) + .filter(\.$id == unwrappedUser1CategoryId) + .filter(\.$user.$id == userId1) + .first() + #expect(category != nil) + } + } +} diff --git a/Tests/GrowBitAppServerTests/GrowBitAppServerFetchingCategoriesTests.swift b/Tests/GrowBitAppServerTests/GrowBitAppServerFetchingCategoriesTests.swift new file mode 100644 index 0000000..f7210c4 --- /dev/null +++ b/Tests/GrowBitAppServerTests/GrowBitAppServerFetchingCategoriesTests.swift @@ -0,0 +1,262 @@ +// +// GrowBitAppServerFetchingCategoriesTests.swift +// GrowBitAppServer +// +// Created by Denis Makarau on 08.10.25. +// + +@testable import GrowBitAppServer +import VaporTesting +import GrowBitSharedDTO +import Testing +import Fluent + +@Suite("Category Fetching Tests") +struct GrowBitAppServerFetchingCategoriesTests { + + @Test("Fetch all categories - Success with multiple categories") + func fetchAllCategoriesSuccess() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "fetchuser1", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + // Retrieve the created user + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "fetchuser1") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Create multiple categories + let categories = [ + ["name": "Work", "colorCode": "#FF0000"], + ["name": "Personal", "colorCode": "#00FF00"], + ["name": "Health", "colorCode": "#0000FF"] + ] + + for category in categories { + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(category) + } afterResponse: { res in + #expect(res.status == .ok) + } + } + + // Fetch all categories + try await app.testing().test(.GET, "/api/\(userId.uuidString)/categories") { req in + // No body needed for GET request + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode([CategoryResponseDTO].self) + #expect(response.count == 3) + + // Verify category names + let categoryNames = response.map { $0.name } + #expect(categoryNames.contains("Work")) + #expect(categoryNames.contains("Personal")) + #expect(categoryNames.contains("Health")) + + // Verify all categories have valid IDs and color codes + for category in response { + #expect(category.id != nil) + #expect(category.colorCode.hasPrefix("#")) + } + } + } + } + + @Test("Fetch all categories - Success with empty result") + func fetchAllCategoriesEmptyResult() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "fetchuser2", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + // Retrieve the created user + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "fetchuser2") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Fetch categories without creating any + try await app.testing().test(.GET, "/api/\(userId.uuidString)/categories") { req in + // No body needed for GET request + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode([CategoryResponseDTO].self) + #expect(response.isEmpty) + } + } + } + + @Test("Fetch all categories - Fail - Invalid userId") + func fetchAllCategoriesInvalidUserId() async throws { + try await withApp(configure: configure) { app in + // Try to fetch categories with invalid UUID + try await app.testing().test(.GET, "/api/invalid-uuid/categories") { req in + // No body needed for GET request + } afterResponse: { res in + #expect(res.status == .badRequest) + } + } + } + + @Test("Fetch all categories - User isolation test") + func fetchAllCategoriesUserIsolation() async throws { + try await withApp(configure: configure) { app in + + // Create two users + + let userRequestBody1 = User(username: "fetchuser3a", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody1) + } afterResponse: { res in + #expect(res.status == .ok) + } + let userRequestBody2 = User(username: "fetchuser3b", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody2) + } afterResponse: { res in + #expect(res.status == .ok) + } + // Retrieve the created users + guard let createdUser1 = try await User.query(on: app.db) + .filter(\.$username == "fetchuser3a") + .first(), + let userId1 = createdUser1.id else { + throw TestError.userCreationFailed + } + guard let createdUser2 = try await User.query(on: app.db) + .filter(\.$username == "fetchuser3b") + .first(), + let userId2 = createdUser2.id else { + throw TestError.userCreationFailed + } + + // Create categories for user1 + let categories1 = [ + ["name": "Work", "colorCode": "#FF0000"], + ["name": "Personal", "colorCode": "#00FF00"], + ["name": "Health", "colorCode": "#0000FF"] + ] + + for category in categories1 { + try await app.testing().test(.POST, "/api/\(userId1.uuidString)/categories") { req in + try req.content.encode(category) + } afterResponse: { res in + #expect(res.status == .ok) + } + } + + // Create categories for user2 + + let categories2 = [ + ["name": "Fitness", "colorCode": "#FFFF00"], + ["name": "Hobbies", "colorCode": "#FF00FF"] + ] + + for category in categories2 { + try await app.testing().test(.POST, "/api/\(userId2.uuidString)/categories") { req in + try req.content.encode(category) + } afterResponse: { res in + #expect(res.status == .ok) + } + } + + // Fetch categories for user1 and verify only user1's categories are returned + + try await app.testing().test(.GET, "/api/\(userId1.uuidString)/categories") { req in + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode([CategoryResponseDTO].self) + #expect(response.count == 3) + + // Verify category names + let categoryNames = response.map { $0.name } + #expect(categoryNames.contains("Work")) + #expect(categoryNames.contains("Personal")) + #expect(categoryNames.contains("Health")) + #expect(!categoryNames.contains("Fitness")) + } + + // Fetch categories for user2 and verify only user2's categories are returned + + try await app.testing().test(.GET, "/api/\(userId2.uuidString)/categories") { req in + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode([CategoryResponseDTO].self) + #expect(response.count == 2) + + // Verify category names + let categoryNames = response.map { $0.name } + #expect(!categoryNames.contains("Work")) + #expect(!categoryNames.contains("Personal")) + #expect(categoryNames.contains("Hobbies")) + #expect(categoryNames.contains("Fitness")) + } + } + + } + + @Test("Fetch all categories - Verify color normalization persistence") + func fetchAllCategoriesColorNormalization() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "fetchuser4", password: "password") + try await app.testing().test(.POST, "/api/register") { req in + try req.content.encode(userRequestBody) + } afterResponse: { res in + #expect(res.status == .ok) + } + + // Retrieve the created user + guard let createdUser = try await User.query(on: app.db) + .filter(\.$username == "fetchuser4") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Create categories with and without # prefix + let categoriesWithColors = [ + ["name": "Cat1", "colorCode": "FF0000"], // Without # + ["name": "Cat2", "colorCode": "#00FF00"] // With # + ] + + for category in categoriesWithColors { + try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in + try req.content.encode(category) + } afterResponse: { res in + #expect(res.status == .ok) + } + } + + // Fetch all categories and verify normalization + try await app.testing().test(.GET, "/api/\(userId.uuidString)/categories") { req in + // No body needed for GET request + } afterResponse: { res in + #expect(res.status == .ok) + let response = try res.content.decode([CategoryResponseDTO].self) + #expect(response.count == 2) + + // All color codes should have # prefix after normalization + for category in response { + #expect(category.colorCode.hasPrefix("#")) + #expect(category.colorCode.count == 7) // # + 6 hex characters + } + } + } + } +} diff --git a/Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift b/Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift index d7988fa..d66fdce 100644 --- a/Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift +++ b/Tests/GrowBitAppServerTests/GrowBitAppServerLoginTests.swift @@ -10,7 +10,6 @@ import Foundation import GrowBitSharedDTO import VaporTesting import Testing -import HabitTrackerAppSharedDTO @Suite("App Login Tests") struct GrowBitAppServerLoginTests { diff --git a/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift b/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift index 894389b..e647664 100644 --- a/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift +++ b/Tests/GrowBitAppServerTests/GrowBitAppServerSavingCategoryTests.swift @@ -185,7 +185,7 @@ struct GrowBitAppServerSavingCategoryTests { // Request body with invalid color code (missing #) let requestBody = [ "name": "test category", - "colorCode": "FFFFFF" + "colorCode": "FF$FF§FF" ] try await app.testing().test(.POST, "/api/\(userId.uuidString)/categories") { req in @@ -214,7 +214,7 @@ struct GrowBitAppServerSavingCategoryTests { throw TestError.userCreationFailed } - // Test various valid color codes + // Test various valid color codes with # let validColors = ["#000000", "#FFFFFF", "#FF0000", "#00FF00", "#0000FF", "#abcdef", "#123456"] for (index, color) in validColors.enumerated() { @@ -233,6 +233,50 @@ struct GrowBitAppServerSavingCategoryTests { } } } + + @Test("Category creation - Success - Color normalization") + func categoryCreationSuccessColorNormalization() async throws { + try await withApp(configure: configure) { app in + // Create a user + let userRequestBody = User(username: "testuser7", 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 == "testuser7") + .first(), + let userId = createdUser.id else { + throw TestError.userCreationFailed + } + + // Test that colors without # are normalized to include # + let testCases: [(input: String, expected: String)] = [ + ("FF0000", "#FF0000"), + ("00FF00", "#00FF00"), + ("0000FF", "#0000FF"), + ("abcdef", "#ABCDEF"), + ("123456", "#123456") + ] + + for (index, testCase) in testCases.enumerated() { + let requestBody = [ + "name": "normalized category \(index)", + "colorCode": testCase.input + ] + + 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() == testCase.expected.uppercased()) + } + } + } + } } enum TestError: Error {