diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..1a31de8 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,131 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Common Commands + +### Development + +```bash +# Get dependencies +dart pub get + +# Run tests +dart test + +# Run specific test file +dart test test/path/to/test_file.dart + +# Analyze code +dart analyze + +# Format code +dart format . + +# Format specific files +dart format lib/ test/ +``` + +### Example Application + +```bash +# Navigate to example directory +cd example/ + +# Create database tables +dart run tools/create_database.dart + +# Start production server (port 8080) +dart run bin/server.dart + +# Start development server with hot reload +dart run --enable-vm-service bin/dev.dart +``` + +## Architecture Overview + +Top Shelf is a Dart package providing helpers, middleware, and architectural patterns for `shelf` HTTP servers. It implements clean architecture with distinct layers: + +### Core Layers + +- **Handlers/Routes**: HTTP endpoint implementations +- **Services**: Business logic layer with validation hooks (`BaseService`) +- **Repositories**: Data access layer (`CrudRepository`, SQLite implementations) +- **Middleware**: Cross-cutting concerns (auth, logging, CORS, validation, etc.) + +### Key Services + +- **Account Service**: User management with PBKDF2 password hashing and role-based access +- **Authentication Service**: JWT-based auth with login/refresh token flow +- **Repository Pattern**: Generic CRUD operations with SQLite3 backend + +### Important Patterns + +- **Dependency Injection**: Services provided via `provide()` middleware +- **Module Organization**: Routes organized by feature and mounted to main router +- **Middleware Pipelines**: Layered middleware application using `Pipeline()` +- **Factory Pattern**: Repository and service factories for clean instantiation + +## Key Dependencies + +- **shelf/shelf_router**: Core HTTP framework +- **sqlite3**: Database persistence +- **pbkdf2**: Secure password hashing (custom fork) +- **logging**: Structured logging support +- **pointycastle**: Cryptographic operations + +## Testing + +Tests are organized in `/test/` with patterns for: + +- Internal utilities (`/test/internal/`) +- Middleware components (`/test/middlewares/`) +- Service layer (`/test/services/`) + +Use `dart test` for full suite or `dart test test/specific_file.dart` for individual test files. + +## Security Features + +- Password hashing with PBKDF2 + pepper +- JWT authentication with refresh tokens +- Request logging with sensitive data masking +- Input validation at service and middleware levels +- Role-based access control + +## In-Memory Repository + +For testing and development without database setup, use the in-memory account repository: + +```dart +// Create in-memory repository +final repository = AccountMemoryRepository(); +final accountService = AccountService(repository, pepperFactory: testPepperFactory); + +// Full CRUD operations available +final account = await accountService.createAccount( + email: 'test@example.com', + password: 'password123', +); + +// Test utilities +final memoryRepo = repository as AccountMemoryRepository; +print('Total accounts: ${memoryRepo.size}'); +memoryRepo.clear(); // Reset for tests +``` + +**Features:** + +- Complete AccountRepositoryInterface implementation +- Email uniqueness enforcement +- Role management (add/remove roles) +- Advanced querying with filters, pagination, sorting +- Account statistics and search functionality +- Test utilities (clear, size, accountIds) + +## Development Notes + +- Example application in `/example/` demonstrates proper usage patterns +- Database setup required via `dart run tools/create_database.dart` in example +- Hot reload available in development mode +- In-memory repository available for testing: `AccountMemoryRepository()` +- Wiki documentation at: https://github.com/KLEAK-Development/top_shelf/wiki diff --git a/example/lib/src/router.dart b/example/lib/src/router.dart index 9b515ed..b468ed7 100644 --- a/example/lib/src/router.dart +++ b/example/lib/src/router.dart @@ -15,15 +15,29 @@ Handler _getRouter() { ..mount( '/authentication', Pipeline() - .addMiddleware(provide( - (request) => SqliteAccountRepository(request.get()))) + .addMiddleware( + provide( + (request) => AccountService( + AccountSqliteRepository( + request.get(), + ), + ), + ), + ) .addHandler(authenticationModule), ) ..mount( '/accounts', Pipeline() - .addMiddleware(provide( - (request) => SqliteAccountRepository(request.get()))) + .addMiddleware( + provide( + (request) => AccountService( + AccountSqliteRepository( + request.get(), + ), + ), + ), + ) .addHandler(accountsModule()), ) ..mount('/todos', todos) @@ -41,7 +55,8 @@ Handler _getRouter() { logRequests: true, logResponses: true, logTiming: true, - // excludePaths: ['/'], // Exclude health check endpoint from logging + logRequestBody: true, + logResponseBody: true, metadataExtractor: (request) => { 'service': 'top_shelf_example', 'version': '1.0.0', diff --git a/example/pubspec.yaml b/example/pubspec.yaml index da5837e..d5af359 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -6,19 +6,19 @@ version: 1.0.0 publish_to: none environment: - sdk: '>=3.3.0 <4.0.0' + sdk: ">=3.3.0 <4.0.0" dependencies: + args: ^2.7.0 logging: ^1.1.1 shelf: ^1.4.0 - top_shelf: + top_shelf: path: ../ shelf_hotreload: ^1.4.0 shelf_router: ^1.1.3 sqlite3: ^2.4.3 xml: ^6.2.2 - dev_dependencies: lints: ^4.0.0 test: ^1.16.0 diff --git a/lib/src/internal/jwt.dart b/lib/src/internal/jwt.dart index 1e1aa16..efcb435 100644 --- a/lib/src/internal/jwt.dart +++ b/lib/src/internal/jwt.dart @@ -3,7 +3,7 @@ import 'dart:io'; import 'package:crypto/crypto.dart'; -String _defaultJwtSecretKeyFactory() { +String defaultJwtSecretKeyFactory() { final secretKey = Platform.environment['JWT_SECRET_KEY']; if (secretKey == null) { throw Exception("JWT_SECRET_KEY environment variable is not set"); @@ -18,10 +18,10 @@ class JsonWebToken { Map _header = {"alg": "HS256", "typ": "JWT"}; Map _payload = {}; - JsonWebToken({this.secretKeyFactory = _defaultJwtSecretKeyFactory}); + JsonWebToken({this.secretKeyFactory = defaultJwtSecretKeyFactory}); - JsonWebToken.parse(String jwt) - : secretKeyFactory = _defaultJwtSecretKeyFactory { + JsonWebToken.parse(String jwt, {String Function()? secretKeyFactory}) + : secretKeyFactory = secretKeyFactory ?? defaultJwtSecretKeyFactory { _jwt = jwt; final parts = jwt.split('.'); @@ -32,9 +32,10 @@ class JsonWebToken { _payload = json.decode(utf8.decode(base64Url.decode(encodedPayload))); } - String get sub => _payload['sub']; - String get iat => _payload['iat']; - String get exp => _payload['exp']; + String? get sub => _payload['sub']; + String? get iat => _payload['iat']; + String? get exp => _payload['exp']; + String get jwt => _jwt; Map get payload => _payload; diff --git a/lib/src/services/accounts/accounts.dart b/lib/src/services/accounts/accounts.dart deleted file mode 100644 index 1c3abc3..0000000 --- a/lib/src/services/accounts/accounts.dart +++ /dev/null @@ -1,2 +0,0 @@ -export 'package:top_shelf/src/services/common/repositories/abstract.dart'; -export 'package:top_shelf/src/services/common/repositories/sqlite3.dart'; diff --git a/lib/src/services/accounts/models/account.dart b/lib/src/services/accounts/models/account.dart index 2763df7..0a6bd01 100644 --- a/lib/src/services/accounts/models/account.dart +++ b/lib/src/services/accounts/models/account.dart @@ -1,6 +1,7 @@ import 'package:top_shelf/top_shelf.dart'; -class Account implements NetworkObjectToJson { +class Account implements NetworkObjectToJson, Entity { + @override final int id; final String email; final String password; @@ -50,7 +51,7 @@ class Account implements NetworkObjectToJson { return { 'id': id, 'email': email, - 'creationDate': creationDate.toIso8601String(), + 'creationDate': creationDate.toUtc().toIso8601String(), 'roles': roles, }; } diff --git a/lib/src/services/accounts/models/default_roles.dart b/lib/src/services/accounts/models/default_roles.dart index 90f30cc..7d1fec7 100644 --- a/lib/src/services/accounts/models/default_roles.dart +++ b/lib/src/services/accounts/models/default_roles.dart @@ -1,3 +1,3 @@ typedef RolesType = List; -const defaultRoles = ['user']; +const defaultRoles = ['admin', 'user']; diff --git a/lib/src/services/accounts/router/accounts.dart b/lib/src/services/accounts/router/accounts.dart index fe2e705..d1a931a 100644 --- a/lib/src/services/accounts/router/accounts.dart +++ b/lib/src/services/accounts/router/accounts.dart @@ -7,6 +7,10 @@ import 'package:top_shelf/src/services/accounts/routes/create_account/middleware as create_account; import 'package:top_shelf/src/services/accounts/routes/create_account/handler.dart' as create_account; +import 'package:top_shelf/src/services/accounts/routes/change_password/middleware.dart' + as change_password; +import 'package:top_shelf/src/services/accounts/routes/change_password/handler.dart' + as change_password; Handler accountsModule({List roles = defaultRoles}) { return Pipeline() @@ -18,6 +22,12 @@ Handler accountsModule({List roles = defaultRoles}) { Pipeline() .addMiddleware(create_account.middleware()) .addHandler(create_account.handler), + ) + ..put( + '/change-password', + Pipeline() + .addMiddleware(change_password.middleware()) + .addHandler(change_password.handler), )) .call), ); diff --git a/lib/src/services/accounts/routes/change_password/change_password.dart b/lib/src/services/accounts/routes/change_password/change_password.dart new file mode 100644 index 0000000..3f6d72a --- /dev/null +++ b/lib/src/services/accounts/routes/change_password/change_password.dart @@ -0,0 +1,17 @@ +import 'package:shelf/shelf.dart'; +import 'package:top_shelf/src/services/accounts/models/account.dart'; +import 'package:top_shelf/src/services/accounts/routes/change_password/models/change_password.dart'; +import 'package:top_shelf/top_shelf.dart'; + +Future handler(Request request, ChangePassword changePassword) async { + final service = request.get(); + final currentAccount = request.get(); + + final updatedAccount = await service.changePassword( + currentAccount.id, + changePassword.currentPassword, + changePassword.newPassword, + ); + + return updatedAccount; +} diff --git a/lib/src/services/accounts/routes/change_password/handler.dart b/lib/src/services/accounts/routes/change_password/handler.dart new file mode 100644 index 0000000..f38d909 --- /dev/null +++ b/lib/src/services/accounts/routes/change_password/handler.dart @@ -0,0 +1,20 @@ +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:top_shelf/src/services/accounts/routes/change_password/models/change_password.dart'; +import 'package:top_shelf/src/services/accounts/routes/change_password/change_password.dart' + as change_password; +import 'package:top_shelf/top_shelf.dart'; + +Future handler(Request request) async { + try { + final object = await change_password.handler( + request, + request.get(), + ); + return generateResponse(request, object, status: HttpStatus.ok); + } on ServiceValidationException catch (_) { + // TODO: we need better validation error + return Response.badRequest(); + } +} diff --git a/lib/src/services/accounts/routes/change_password/middleware.dart b/lib/src/services/accounts/routes/change_password/middleware.dart new file mode 100644 index 0000000..be8c8ad --- /dev/null +++ b/lib/src/services/accounts/routes/change_password/middleware.dart @@ -0,0 +1,30 @@ +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:top_shelf/src/middlewares/allowed_content_type.dart'; +import 'package:top_shelf/src/middlewares/body_validator.dart'; +import 'package:top_shelf/src/middlewares/get_body.dart'; +import 'package:top_shelf/src/middlewares/parse_body.dart'; +import 'package:top_shelf/src/services/accounts/routes/change_password/models/change_password.dart'; +import 'package:top_shelf/src/services/accounts/routes/change_password/models/change_password_body.dart'; +import 'package:top_shelf/src/services/common/middlewares/auth/jwt_auth.dart'; + +Middleware middleware({String Function()? secretKeyFactory}) => Pipeline() + .addMiddleware( + jwtAuth(secretKeyFactory: secretKeyFactory), + ) // Authenticate user via JWT + .addMiddleware(allowedContentType([ + ContentType('application', 'json'), + ContentType('application', 'xml'), + ContentType('application', 'x-www-form-urlencoded'), + ContentType('multipart', 'form-data'), + ])) + .addMiddleware(getBody((body) => ChangePasswordBody(body), + objectName: 'ChangePassword')) + .addMiddleware(bodyFieldIsRequired('currentPassword')) + .addMiddleware( + bodyFieldIsType('currentPassword')) + .addMiddleware(bodyFieldIsRequired('newPassword')) + .addMiddleware(bodyFieldIsType('newPassword')) + .addMiddleware(parseBody()) + .middleware; diff --git a/lib/src/services/accounts/routes/change_password/models/change_password.dart b/lib/src/services/accounts/routes/change_password/models/change_password.dart new file mode 100644 index 0000000..8110575 --- /dev/null +++ b/lib/src/services/accounts/routes/change_password/models/change_password.dart @@ -0,0 +1,18 @@ +class ChangePassword { + final String currentPassword; + final String newPassword; + + const ChangePassword(this.currentPassword, this.newPassword); + + factory ChangePassword.fromJson(Map json) { + if (json + case { + 'currentPassword': final String currentPassword, + 'newPassword': final String newPassword + }) { + return ChangePassword(currentPassword, newPassword); + } else { + throw FormatException('Unexpected JSON'); + } + } +} diff --git a/lib/src/services/accounts/routes/change_password/models/change_password_body.dart b/lib/src/services/accounts/routes/change_password/models/change_password_body.dart new file mode 100644 index 0000000..1e2deac --- /dev/null +++ b/lib/src/services/accounts/routes/change_password/models/change_password_body.dart @@ -0,0 +1,9 @@ +import 'package:top_shelf/src/internal/body.dart'; +import 'package:top_shelf/src/services/accounts/routes/change_password/models/change_password.dart'; + +class ChangePasswordBody extends Body { + ChangePasswordBody(super.data); + + @override + ChangePassword parse() => ChangePassword.fromJson(data); +} diff --git a/lib/src/services/accounts/routes/create_account/create_account.dart b/lib/src/services/accounts/routes/create_account/create_account.dart index d40ceb5..6fa47c8 100644 --- a/lib/src/services/accounts/routes/create_account/create_account.dart +++ b/lib/src/services/accounts/routes/create_account/create_account.dart @@ -1,21 +1,16 @@ -import 'package:pbkdf2/pbkdf2.dart'; import 'package:shelf/shelf.dart'; -import 'package:top_shelf/src/internal/request.dart'; import 'package:top_shelf/src/services/accounts/models/account.dart'; import 'package:top_shelf/src/services/accounts/models/default_roles.dart'; import 'package:top_shelf/src/services/accounts/routes/create_account/models/create_account.dart'; -import 'package:top_shelf/src/services/common/pepper_factory.dart'; -import 'package:top_shelf/src/services/common/repositories/abstract.dart'; +import 'package:top_shelf/top_shelf.dart'; Future handler(Request request, CreateAccount createAccount) async { - final accountRepository = request.get(); - final pbkdf2 = Pbkdf2(pepperFactory: defaultPepperFactory); - final hashedPassword = pbkdf2.hash(createAccount.password); + final service = request.get(); - final account = await accountRepository.create( - createAccount.email, - hashedPassword, - request.get(), + final account = await service.createAccount( + email: createAccount.email, + password: createAccount.password, + roles: request.get(), ); return account; diff --git a/lib/src/services/authentication/routes/login/handler.dart b/lib/src/services/authentication/routes/login/handler.dart index b68e9ee..fa73174 100644 --- a/lib/src/services/authentication/routes/login/handler.dart +++ b/lib/src/services/authentication/routes/login/handler.dart @@ -1,12 +1,9 @@ -import 'dart:io'; - import 'package:shelf/shelf.dart'; import 'package:top_shelf/src/internal/generate_response.dart'; import 'package:top_shelf/src/internal/request.dart'; import 'package:top_shelf/src/services/authentication/routes/login/models/login.dart'; import 'package:top_shelf/src/services/authentication/routes/login/login.dart' as login; -import 'package:top_shelf/src/services/common/repositories/abstract.dart'; Future handler(Request request) async { try { @@ -15,7 +12,7 @@ Future handler(Request request) async { request.get(), ); return generateResponse(request, object); - } on AccountNotFound catch (_) { - return Response(HttpStatus.notFound); + } on login.AccountNotFound catch (_) { + return Response.unauthorized(''); } } diff --git a/lib/src/services/authentication/routes/login/login.dart b/lib/src/services/authentication/routes/login/login.dart index 4295d69..8cafdd0 100644 --- a/lib/src/services/authentication/routes/login/login.dart +++ b/lib/src/services/authentication/routes/login/login.dart @@ -1,37 +1,18 @@ -import 'package:pbkdf2/pbkdf2.dart'; import 'package:shelf/shelf.dart'; -import 'package:top_shelf/src/internal/jwt.dart'; -import 'package:top_shelf/src/internal/request.dart'; -import 'package:top_shelf/src/services/common/middlewares/get_account_if_exist.dart'; -import 'package:top_shelf/src/services/accounts/models/account.dart'; import 'package:top_shelf/src/services/authentication/routes/login/models/login.dart'; import 'package:top_shelf/src/services/authentication/models/tokens.dart'; -import 'package:top_shelf/src/services/common/pepper_factory.dart'; -import 'package:top_shelf/src/services/common/repositories/abstract.dart'; +import 'package:top_shelf/top_shelf.dart'; -Future handler(Request request, Login login) async { - final accountExist = request.get(); - if (!accountExist) { - throw AccountNotFound(); - } - - final account = request.get(); +class AccountNotFound {} - final pbkdf2 = Pbkdf2(pepperFactory: defaultPepperFactory); - final isValidPassword = pbkdf2.verify(login.password, account.password); +Future handler(Request request, Login login) async { + final service = request.get(); + final account = await service.authenticate(login.email, login.password); - if (!isValidPassword) { + if (account == null) { // TODO(kevin): should we use a more specific error ? throw AccountNotFound(); } - var jwt = JsonWebToken(); - jwt.createPayload(account.id.toString()); - final accessToken = jwt.sign(); - - jwt = JsonWebToken(); - jwt.createPayload(account.id.toString(), expireIn: Duration(days: 30)); - final resfreshToken = jwt.sign(); - - return Tokens(accessToken, resfreshToken); + return service.login(account); } diff --git a/lib/src/services/authentication/routes/login/middleware.dart b/lib/src/services/authentication/routes/login/middleware.dart index b5b0372..d42c672 100644 --- a/lib/src/services/authentication/routes/login/middleware.dart +++ b/lib/src/services/authentication/routes/login/middleware.dart @@ -6,7 +6,6 @@ import 'package:top_shelf/src/middlewares/allowed_content_type.dart'; import 'package:top_shelf/src/middlewares/body_validator.dart'; import 'package:top_shelf/src/middlewares/get_body.dart'; import 'package:top_shelf/src/middlewares/parse_body.dart'; -import 'package:top_shelf/src/services/common/middlewares/get_account_if_exist.dart'; import 'package:top_shelf/src/services/authentication/routes/login/models/login.dart'; import 'package:top_shelf/src/services/authentication/routes/login/models/login_body.dart'; @@ -24,5 +23,4 @@ Middleware middleware() => Pipeline() .addMiddleware(bodyFieldIsRequired('password')) .addMiddleware(bodyFieldIsType('password')) .addMiddleware(parseBody()) - .addMiddleware(getAccountIfExist()) .middleware; diff --git a/lib/src/services/authentication/routes/refresh/models/refresh_body.dart b/lib/src/services/authentication/routes/refresh/models/refresh_body.dart index 865e4fe..57f0bd5 100644 --- a/lib/src/services/authentication/routes/refresh/models/refresh_body.dart +++ b/lib/src/services/authentication/routes/refresh/models/refresh_body.dart @@ -1,9 +1,9 @@ import 'package:top_shelf/src/internal/body.dart'; -import 'package:top_shelf/src/services/authentication/routes/login/models/login.dart'; +import 'package:top_shelf/src/services/authentication/routes/refresh/models/refresh.dart'; -class RefreshBody extends Body { +class RefreshBody extends Body { RefreshBody(super.data); @override - Login parse() => Login.fromJson(data); + Refresh parse() => Refresh.fromJson(data); } diff --git a/lib/src/services/authentication/routes/refresh/refresh.dart b/lib/src/services/authentication/routes/refresh/refresh.dart index 715adc7..88d73bc 100644 --- a/lib/src/services/authentication/routes/refresh/refresh.dart +++ b/lib/src/services/authentication/routes/refresh/refresh.dart @@ -2,18 +2,12 @@ import 'package:shelf/shelf.dart'; import 'package:top_shelf/src/internal/jwt.dart'; import 'package:top_shelf/src/internal/request.dart'; import 'package:top_shelf/src/services/authentication/models/tokens.dart'; +import 'package:top_shelf/src/services/common/services/account/account_service_interface.dart'; Future handler(Request request) async { final refreshToken = request.get(); - final userId = refreshToken.sub; + final service = request.get(); - var jwt = JsonWebToken(); - jwt.createPayload(userId); - final accessToken = jwt.sign(); - - jwt = JsonWebToken(); - jwt.createPayload(userId, expireIn: Duration(days: 30)); - final newRefreshToken = jwt.sign(); - - return Tokens(accessToken, newRefreshToken); + // Use the AccountService to handle token refresh logic + return await service.refreshTokens(refreshToken.jwt); } diff --git a/lib/src/services/common/middlewares/auth/jwt_auth.dart b/lib/src/services/common/middlewares/auth/jwt_auth.dart new file mode 100644 index 0000000..8f64016 --- /dev/null +++ b/lib/src/services/common/middlewares/auth/jwt_auth.dart @@ -0,0 +1,56 @@ +import 'package:shelf/shelf.dart'; +import 'package:top_shelf/src/internal/jwt.dart'; +import 'package:top_shelf/src/internal/request.dart'; +import 'package:top_shelf/src/services/accounts/models/account.dart'; +import 'package:top_shelf/src/services/common/services/account/account_service_interface.dart'; + +/// Middleware that validates JWT Bearer token and extracts the associated account +Middleware jwtAuth({String Function()? secretKeyFactory}) { + return (handler) { + return (request) async { + final authHeader = request.headers['authorization']; + + // Check if Authorization header is present and starts with 'Bearer ' + if (authHeader == null || !authHeader.startsWith('Bearer ')) { + return Response.unauthorized('Missing or invalid authorization header'); + } + + // Extract token from header + final token = authHeader.substring('Bearer '.length); + + try { + // Parse and verify JWT + final jwt = JsonWebToken.parse( + token, + secretKeyFactory: secretKeyFactory, + ); + if (!jwt.verify()) { + return Response.unauthorized('Invalid or expired token'); + } + + // Extract user ID from token payload + final userId = jwt.sub; + if (userId == null || userId.isEmpty) { + return Response.unauthorized('Invalid token payload'); + } + + // Get account service and fetch the account + final accountService = request.get(); + final accountId = int.tryParse(userId); + if (accountId == null) { + return Response.unauthorized('Invalid user ID in token'); + } + + try { + final account = await accountService.getById(accountId); + // Add account to request context + return handler(request.set(() => account)); + } catch (e) { + return Response.unauthorized('Account not found'); + } + } catch (e) { + return Response.unauthorized('Invalid token format'); + } + }; + }; +} diff --git a/lib/src/services/common/middlewares/get_account_if_exist.dart b/lib/src/services/common/middlewares/get_account_if_exist.dart index 4aadf5f..839da26 100644 --- a/lib/src/services/common/middlewares/get_account_if_exist.dart +++ b/lib/src/services/common/middlewares/get_account_if_exist.dart @@ -2,7 +2,7 @@ import 'package:shelf/shelf.dart'; import 'package:top_shelf/src/internal/request.dart'; import 'package:top_shelf/src/services/accounts/models/account.dart'; import 'package:top_shelf/src/services/common/models/has_email.dart'; -import 'package:top_shelf/src/services/common/repositories/abstract.dart'; +import 'package:top_shelf/src/services/common/services/account/account_service_interface.dart'; typedef AccountExist = bool; @@ -10,16 +10,14 @@ Middleware getAccountIfExist() { return (handler) { return (request) async { final objectWithEmail = request.get(); - final repository = request.get(); + final service = request.get(); - final optionalAccount = - await repository.findAccountByEmail(objectWithEmail.email); + final account = + await service.repository.findByEmail(objectWithEmail.email); - var modifiedRequest = - request.set(() => optionalAccount.isPresent); - if (optionalAccount.isPresent) { - modifiedRequest = - modifiedRequest.set(() => optionalAccount.value); + var modifiedRequest = request.set(() => account != null); + if (account != null) { + modifiedRequest = modifiedRequest.set(() => account); } return handler(modifiedRequest); diff --git a/lib/src/services/common/repositories/abstract.dart b/lib/src/services/common/repositories/abstract.dart deleted file mode 100644 index 5ea5309..0000000 --- a/lib/src/services/common/repositories/abstract.dart +++ /dev/null @@ -1,12 +0,0 @@ -import 'dart:async'; - -import 'package:quiver/core.dart'; -import 'package:top_shelf/src/services/accounts/models/account.dart'; - -abstract class AAccountsRepository { - FutureOr> findAccountByEmail(final String email); - FutureOr create( - final String email, final String password, final List roles); -} - -class AccountNotFound implements Exception {} diff --git a/lib/src/services/common/repositories/account/account_memory_repository.dart b/lib/src/services/common/repositories/account/account_memory_repository.dart new file mode 100644 index 0000000..c9292cd --- /dev/null +++ b/lib/src/services/common/repositories/account/account_memory_repository.dart @@ -0,0 +1,474 @@ +import 'dart:async'; + +import 'package:top_shelf/src/services/accounts/models/account.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_repository_interface.dart'; +import 'package:top_shelf/src/services/common/repositories/crud_repository.dart'; + +/// In-memory implementation of AccountRepositoryInterface +/// Useful for testing and development scenarios where database setup is not needed +class AccountMemoryRepository implements AccountRepositoryInterface { + final Map _accounts = {}; + int _nextId = 1; + + @override + FutureOr create(Account entity) { + if (entity.id != 0) { + throw ArgumentError('Entity already has an ID. Use update() instead.'); + } + + // Check for email uniqueness + if (_accounts.values.any((account) => account.email == entity.email)) { + throw EntityConstraintException( + 'Email already exists: ${entity.email}', + 'unique_email', + ); + } + + final newAccount = Account( + _nextId++, + entity.email, + entity.password, + entity.creationDate, + List.from(entity.roles), + ); + + _accounts[newAccount.id] = newAccount; + return newAccount; + } + + @override + FutureOr> createAll(List entities) async { + final results = []; + for (final entity in entities) { + results.add(await create(entity)); + } + return results; + } + + @override + FutureOr findById(int id) { + return _accounts[id]; + } + + @override + FutureOr getById(int id) { + final account = _accounts[id]; + if (account == null) { + throw EntityNotFoundException('Account not found', id); + } + return account; + } + + @override + FutureOr> findAll([QueryParams? params]) { + var accounts = _accounts.values.toList(); + + // Apply filters + if (params?.filters.isNotEmpty == true) { + accounts = accounts.where((account) { + return params!.filters.entries.every((entry) { + final key = entry.key; + final value = entry.value; + + switch (key) { + case 'email': + if (value is String && value.contains('%')) { + // Handle LIKE patterns + final pattern = value.replaceAll('%', ''); + return account.email.contains(pattern); + } + return account.email == value; + case 'roles': + if (value is String) { + return account.roles.contains(value); + } + return false; + case 'id': + return account.id == value; + default: + return true; + } + }); + }).toList(); + } + + // Apply ordering + if (params?.orderBy.isNotEmpty == true) { + final orderBy = params!.orderBy.first; + accounts.sort((a, b) { + int comparison; + switch (orderBy) { + case 'email': + comparison = a.email.compareTo(b.email); + break; + case 'creationDate': + comparison = a.creationDate.compareTo(b.creationDate); + break; + case 'id': + comparison = a.id.compareTo(b.id); + break; + default: + comparison = 0; + } + return params.ascending ? comparison : -comparison; + }); + } + + // Apply pagination + if (params?.offset != null || params?.limit != null) { + final offset = params?.offset ?? 0; + final limit = params?.limit; + + if (offset > 0) { + accounts = accounts.skip(offset).toList(); + } + + if (limit != null) { + accounts = accounts.take(limit).toList(); + } + } + + return accounts; + } + + @override + FutureOr> findPage(QueryParams params) async { + final totalCount = await count(QueryParams(filters: params.filters)); + final items = await findAll(params); + + final offset = params.offset ?? 0; + final limit = params.limit ?? totalCount; + + return PagedResult( + items: items, + totalCount: totalCount, + limit: limit, + offset: offset, + hasNext: offset + items.length < totalCount, + hasPrevious: offset > 0, + ); + } + + @override + FutureOr findFirst(QueryParams params) async { + final results = await findAll(params.copyWith(limit: 1)); + return results.isEmpty ? null : results.first; + } + + @override + FutureOr findOne(QueryParams params) async { + final results = await findAll(params.copyWith(limit: 2)); + + if (results.isEmpty) { + throw EntityNotFoundException('No account found matching criteria', null); + } + + if (results.length > 1) { + throw EntityConstraintException( + 'Multiple accounts found, expected exactly one', + 'unique_result', + ); + } + + return results.first; + } + + @override + FutureOr existsById(int id) { + return _accounts.containsKey(id); + } + + @override + FutureOr count([QueryParams? params]) async { + if (params?.filters.isEmpty ?? true) { + return _accounts.length; + } + + final filtered = await findAll(params); + return filtered.length; + } + + @override + FutureOr update(Account entity) { + if (!_accounts.containsKey(entity.id)) { + throw EntityNotFoundException('Account not found', entity.id); + } + + // Check for email uniqueness (excluding current account) + final existingWithEmail = _accounts.values + .where((account) => + account.email == entity.email && account.id != entity.id) + .isNotEmpty; + + if (existingWithEmail) { + throw EntityConstraintException( + 'Email already exists: ${entity.email}', + 'unique_email', + ); + } + + final updatedAccount = Account( + entity.id, + entity.email, + entity.password, + entity.creationDate, + List.from(entity.roles), + ); + + _accounts[entity.id] = updatedAccount; + return updatedAccount; + } + + @override + FutureOr> updateAll(List entities) { + final results = []; + for (final entity in entities) { + results.add(update(entity) as Account); + } + return results; + } + + @override + FutureOr updatePartial(int id, Map fields) { + final account = _accounts[id]; + if (account == null) { + throw EntityNotFoundException('Account not found', id); + } + + // Create updated account with partial fields + final updatedAccount = Account( + account.id, + fields['email'] ?? account.email, + fields['password'] ?? account.password, + fields['creationDate'] != null + ? DateTime.parse(fields['creationDate']) + : account.creationDate, + fields['roles'] != null + ? (fields['roles'] as String).split(',') + : account.roles, + ); + + return update(updatedAccount) as Account; + } + + @override + FutureOr deleteById(int id) { + return _accounts.remove(id) != null; + } + + @override + FutureOr delete(Account entity) { + return deleteById(entity.id) as bool; + } + + @override + FutureOr deleteAllById(List ids) { + int deletedCount = 0; + for (final id in ids) { + if (_accounts.remove(id) != null) { + deletedCount++; + } + } + return deletedCount; + } + + @override + FutureOr deleteWhere(QueryParams params) async { + final toDelete = await findAll(params); + final ids = toDelete.map((account) => account.id).toList(); + return deleteAllById(ids) as int; + } + + @override + FutureOr deleteAll() { + final count = _accounts.length; + _accounts.clear(); + _nextId = 1; + return count; + } + + @override + FutureOr save(Account entity) { + if (entity.id == 0 || !_accounts.containsKey(entity.id)) { + return create(entity); + } else { + return update(entity); + } + } + + @override + FutureOr> saveAll(List entities) { + final results = []; + for (final entity in entities) { + results.add(save(entity) as Account); + } + return results; + } + + // AccountRepositoryInterface specific methods + + @override + Future createAccountWithDefaults( + String email, + String hashedPassword, { + List roles = const ['user'], + }) async { + final account = Account( + 0, // Will be assigned by create() + email, + hashedPassword, + DateTime.now(), + roles, + ); + + return await create(account); + } + + @override + Future findByEmail(String email) async { + return await findFirst(QueryParams(filters: {'email': email})); + } + + @override + Future> findByRole(String role) async { + return await findAll(QueryParams(filters: {'roles': role})); + } + + @override + Future> findByDateRange( + DateTime startDate, DateTime endDate) async { + final accounts = _accounts.values.where((account) { + return account.creationDate.isAfter(startDate) && + account.creationDate.isBefore(endDate); + }).toList(); + + return accounts; + } + + @override + Future updatePassword( + int accountId, String newHashedPassword) async { + final account = await getById(accountId); + + final updatedAccount = Account( + account.id, + account.email, + newHashedPassword, + account.creationDate, + account.roles, + ); + + return await update(updatedAccount); + } + + @override + Future addRole(int accountId, String role) async { + final account = await getById(accountId); + + if (account.roles.contains(role)) { + return account; // Role already exists + } + + final updatedRoles = [...account.roles, role]; + final updatedAccount = Account( + account.id, + account.email, + account.password, + account.creationDate, + updatedRoles, + ); + + return await update(updatedAccount); + } + + @override + Future removeRole(int accountId, String role) async { + final account = await getById(accountId); + + final updatedRoles = account.roles.where((r) => r != role).toList(); + final updatedAccount = Account( + account.id, + account.email, + account.password, + account.creationDate, + updatedRoles, + ); + + return await update(updatedAccount); + } + + @override + Future isEmailTaken(String email) async { + return _accounts.values.any((account) => account.email == email); + } + + @override + Future> searchAccounts({ + String? emailSearch, + String? roleFilter, + int limit = 20, + int offset = 0, + bool orderByCreationDate = true, + }) async { + final filters = {}; + + if (emailSearch != null && emailSearch.isNotEmpty) { + filters['email'] = '%$emailSearch%'; + } + + if (roleFilter != null && roleFilter.isNotEmpty) { + filters['roles'] = roleFilter; + } + + final params = QueryParams( + filters: filters, + orderBy: orderByCreationDate ? ['creationDate'] : ['id'], + limit: limit, + offset: offset, + ascending: !orderByCreationDate, // Most recent first for creation date + ); + + return await findPage(params); + } + + @override + Future> getAccountStats() async { + final totalAccounts = _accounts.length; + final roleStats = {}; + + for (final account in _accounts.values) { + for (final role in account.roles) { + roleStats[role] = (roleStats[role] ?? 0) + 1; + } + } + + final now = DateTime.now(); + final last30Days = now.subtract(Duration(days: 30)); + final recentAccounts = _accounts.values + .where((account) => account.creationDate.isAfter(last30Days)) + .length; + + final totalRoles = + roleStats.values.fold(0, (sum, count) => sum + count); + + return { + 'total_accounts': totalAccounts, + 'recent_accounts_30_days': recentAccounts, + 'role_distribution': roleStats, + 'average_roles_per_account': + totalAccounts > 0 ? totalRoles / totalAccounts : 0.0, + }; + } + + /// Clears all data (useful for testing) + void clear() { + _accounts.clear(); + _nextId = 1; + } + + /// Returns the current number of accounts (useful for testing) + int get size => _accounts.length; + + /// Returns all account IDs (useful for testing) + List get accountIds => _accounts.keys.toList(); +} diff --git a/lib/src/services/common/repositories/account/account_repository_interface.dart b/lib/src/services/common/repositories/account/account_repository_interface.dart new file mode 100644 index 0000000..40339b5 --- /dev/null +++ b/lib/src/services/common/repositories/account/account_repository_interface.dart @@ -0,0 +1,48 @@ +import 'dart:async'; +import 'package:top_shelf/src/services/accounts/models/account.dart'; +import 'package:top_shelf/src/services/common/repositories/crud_repository.dart'; + +/// Abstract interface for account repository operations +/// This allows the AccountService to work with different database implementations +abstract class AccountRepositoryInterface + implements CrudRepository { + /// Creates a new account with hashed password and default roles + Future createAccountWithDefaults( + String email, + String hashedPassword, { + List roles = const ['user'], + }); + + /// Finds an account by email address + Future findByEmail(String email); + + /// Finds accounts by role using LIKE pattern + Future> findByRole(String role); + + /// Finds accounts created within a date range + Future> findByDateRange(DateTime startDate, DateTime endDate); + + /// Updates account password + Future updatePassword(int accountId, String newHashedPassword); + + /// Adds a role to an account + Future addRole(int accountId, String role); + + /// Removes a role from an account + Future removeRole(int accountId, String role); + + /// Checks if email is already taken + Future isEmailTaken(String email); + + /// Searches accounts with pagination and filtering + Future> searchAccounts({ + String? emailSearch, + String? roleFilter, + int limit = 20, + int offset = 0, + bool orderByCreationDate = true, + }); + + /// Gets account statistics + Future> getAccountStats(); +} diff --git a/lib/src/services/common/repositories/account/account_sqlite_repository.dart b/lib/src/services/common/repositories/account/account_sqlite_repository.dart new file mode 100644 index 0000000..4c16c17 --- /dev/null +++ b/lib/src/services/common/repositories/account/account_sqlite_repository.dart @@ -0,0 +1,526 @@ +import 'dart:async'; + +import 'package:top_shelf/src/services/accounts/models/account.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_repository_interface.dart'; +import 'package:top_shelf/src/services/common/repositories/crud_repository.dart'; +import 'package:top_shelf/src/services/common/repositories/sqlite_crud_repository.dart'; + +class AccountSqliteRepository extends SqliteCrudRepository + implements AccountRepositoryInterface { + AccountSqliteRepository(super.database); + + @override + Account fromMap(Map map) { + return Account.fromJson(map); + } + + @override + Map toMap(Account entity) { + return { + 'id': entity.id, + 'email': entity.email, + 'password': entity.password, + 'creationDate': entity.creationDate.toUtc().toIso8601String(), + 'roles': entity.roles.join(','), + }; + } + + @override + String get createSql => + 'INSERT INTO accounts (email, password, creationDate, roles) VALUES (?, ?, ?, ?) RETURNING id, email, password, creationDate, roles'; + + @override + String get findByIdSql => + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE id = ?'; + + @override + String get findAllSql => + 'SELECT id, email, password, creationDate, roles FROM accounts'; + + @override + String get countSql => 'SELECT COUNT(*) as count FROM accounts'; + + @override + String get updateByIdSql => + 'UPDATE accounts SET email = ?, password = ?, creationDate = ?, roles = ? WHERE id = ? RETURNING id, email, password, creationDate, roles'; + + @override + String get deleteByIdSql => 'DELETE FROM accounts WHERE id = ?'; + + @override + List getInsertValues(Account entity) { + return [ + entity.email, + entity.password, + entity.creationDate.toUtc().toIso8601String(), + entity.roles.join(','), + ]; + } + + @override + List getUpdateValues(Account entity) { + return [ + entity.email, + entity.password, + entity.creationDate.toUtc().toIso8601String(), + entity.roles.join(','), + entity.id, + ]; + } + + @override + Future> findWithParams(QueryParams params) async { + // Handle specific query patterns with hardcoded SQL + if (params.filters.length == 1) { + final key = params.filters.keys.first; + final value = params.filters.values.first; + + if (key == 'email') { + if (value is String && value.contains('%')) { + return await _findByEmailLike(value, params); + } else { + return await _findByEmailExact(value, params); + } + } else if (key == 'roles') { + return await _findByRoles(value, params); + } + } + + // For complex filters, fall back to multiple simple queries + return await _findWithComplexFilters(params); + } + + @override + Future countWithParams(QueryParams params) async { + if (params.filters.length == 1) { + final key = params.filters.keys.first; + final value = params.filters.values.first; + + if (key == 'email') { + final results = database.select( + 'SELECT COUNT(*) as count FROM accounts WHERE email = ?', [value]); + return results.first['count'] as int; + } else if (key == 'roles') { + // Wrap the role value with % for LIKE pattern matching + final rolePattern = value.toString().contains('%') + ? value.toString() + : '%${value.toString()}%'; + final results = database.select( + 'SELECT COUNT(*) as count FROM accounts WHERE roles LIKE ?', + [rolePattern], + ); + return results.first['count'] as int; + } + } + + // For complex counting, count all matching results + final items = await findWithParams(params); + return items.length; + } + + /// Find account by exact email match + Future> _findByEmailExact( + String email, QueryParams params) async { + String sql; + final sqlParams = [email]; + + // Use specific hardcoded SQL based on ordering and limits + if (params.orderBy.isNotEmpty && params.orderBy.first == 'creationDate') { + if (params.ascending) { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email = ? ORDER BY creationDate ASC'; + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email = ? ORDER BY creationDate DESC'; + } + } else if (params.orderBy.isNotEmpty && params.orderBy.first == 'email') { + if (params.ascending) { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email = ? ORDER BY email ASC'; + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email = ? ORDER BY email DESC'; + } + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email = ?'; + } + + final results = database.select(sql, sqlParams); + var items = results.map((row) => fromMap(row)).toList(); + + // Apply pagination in memory for simplicity + if (params.offset != null && params.offset! > 0) { + items = items.skip(params.offset!).toList(); + } + if (params.limit != null) { + items = items.take(params.limit!).toList(); + } + + return items; + } + + /// Find accounts by email LIKE pattern + Future> _findByEmailLike( + String emailPattern, QueryParams params) async { + String sql; + final sqlParams = [emailPattern]; + + // Use specific hardcoded SQL based on ordering + if (params.orderBy.isNotEmpty && params.orderBy.first == 'creationDate') { + if (params.ascending) { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email LIKE ? ORDER BY creationDate ASC'; + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email LIKE ? ORDER BY creationDate DESC'; + } + } else if (params.orderBy.isNotEmpty && params.orderBy.first == 'email') { + if (params.ascending) { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email LIKE ? ORDER BY email ASC'; + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email LIKE ? ORDER BY email DESC'; + } + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email LIKE ?'; + } + + final results = database.select(sql, sqlParams); + var items = results.map((row) => fromMap(row)).toList(); + + // Apply pagination in memory for simplicity + if (params.offset != null && params.offset! > 0) { + items = items.skip(params.offset!).toList(); + } + if (params.limit != null) { + items = items.take(params.limit!).toList(); + } + + return items; + } + + /// Find accounts by roles (supports LIKE pattern) + Future> _findByRoles( + dynamic rolesValue, QueryParams params) async { + String sql; + // Wrap the role value with % for LIKE pattern matching + final rolePattern = rolesValue.toString().contains('%') + ? rolesValue.toString() + : '%${rolesValue.toString()}%'; + final sqlParams = [rolePattern]; + + // Use specific hardcoded SQL based on ordering + if (params.orderBy.isNotEmpty && params.orderBy.first == 'creationDate') { + if (params.ascending) { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE roles LIKE ? ORDER BY creationDate ASC'; + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE roles LIKE ? ORDER BY creationDate DESC'; + } + } else if (params.orderBy.isNotEmpty && params.orderBy.first == 'email') { + if (params.ascending) { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE roles LIKE ? ORDER BY email ASC'; + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE roles LIKE ? ORDER BY email DESC'; + } + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE roles LIKE ?'; + } + + final results = database.select(sql, sqlParams); + var items = results.map((row) => fromMap(row)).toList(); + + // Apply pagination in memory for simplicity + if (params.offset != null && params.offset! > 0) { + items = items.skip(params.offset!).toList(); + } + if (params.limit != null) { + items = items.take(params.limit!).toList(); + } + + return items; + } + + /// Handle complex filters by combining simple queries + Future> _findWithComplexFilters(QueryParams params) async { + if (params.filters.isEmpty) { + String sql; + + // Use specific hardcoded SQL based on ordering + if (params.orderBy.isNotEmpty && params.orderBy.first == 'creationDate') { + if (params.ascending) { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts ORDER BY creationDate ASC'; + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts ORDER BY creationDate DESC'; + } + } else if (params.orderBy.isNotEmpty && params.orderBy.first == 'email') { + if (params.ascending) { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts ORDER BY email ASC'; + } else { + sql = + 'SELECT id, email, password, creationDate, roles FROM accounts ORDER BY email DESC'; + } + } else { + sql = 'SELECT id, email, password, creationDate, roles FROM accounts'; + } + + final dbResults = database.select(sql); + var results = dbResults.map((row) => fromMap(row)).toList(); + + // Apply pagination in memory for simplicity + if (params.offset != null && params.offset! > 0) { + results = results.skip(params.offset!).toList(); + } + if (params.limit != null) { + results = results.take(params.limit!).toList(); + } + + return results; + } else { + // For complex filter combinations, use specific repository methods instead + throw RepositoryException( + 'Complex filter combinations not yet implemented. Use specific repository methods instead.'); + } + } + + /// Find account by email address (convenience method) + @override + Future findByEmail(String email) async { + final results = database.select( + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email = ?', + [email]); + return results.isEmpty ? null : fromMap(results.first); + } + + /// Find accounts by role using LIKE + @override + Future> findByRole(String role) async { + final results = database.select( + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE roles LIKE ?', + ['%$role%']); + return results.map((row) => fromMap(row)).toList(); + } + + /// Find accounts created within a date range + @override + Future> findByDateRange( + DateTime startDate, DateTime endDate) async { + final results = database.select( + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE creationDate >= ? AND creationDate <= ? ORDER BY creationDate DESC', + [ + startDate.toUtc().toIso8601String(), + endDate.toUtc().toIso8601String() + ]); + return results.map((row) => fromMap(row)).toList(); + } + + /// Create account with hashed password and default roles + @override + Future createAccountWithDefaults( + String email, + String hashedPassword, { + List roles = const ['user'], + }) async { + final results = database.select( + 'INSERT INTO accounts (email, password, creationDate, roles) VALUES (?, ?, ?, ?) RETURNING id, email, password, creationDate, roles', + [ + email, + hashedPassword, + DateTime.now().toUtc().toIso8601String(), + roles.join(','), + ]); + return fromMap(results.first); + } + + /// Update account password + @override + Future updatePassword( + int accountId, String newHashedPassword) async { + final results = database.select( + 'UPDATE accounts SET password = ? WHERE id = ? RETURNING id, email, password, creationDate, roles', + [newHashedPassword, accountId]); + if (results.isEmpty) { + throw EntityNotFoundException( + 'Account not found for password update', accountId); + } + return fromMap(results.first); + } + + /// Add role to account + @override + Future addRole(int accountId, String role) async { + final account = await getById(accountId); + if (!account.roles.contains(role)) { + final updatedRoles = [...account.roles, role]; + final results = database.select( + 'UPDATE accounts SET roles = ? WHERE id = ? RETURNING id, email, password, creationDate, roles', + [updatedRoles.join(','), accountId]); + return fromMap(results.first); + } + return account; + } + + /// Remove role from account + @override + Future removeRole(int accountId, String role) async { + final account = await getById(accountId); + final updatedRoles = account.roles.where((r) => r != role).toList(); + final results = database.select( + 'UPDATE accounts SET roles = ? WHERE id = ? RETURNING id, email, password, creationDate, roles', + [updatedRoles.join(','), accountId]); + return fromMap(results.first); + } + + /// Check if email is already taken + @override + Future isEmailTaken(String email) async { + final results = database.select( + 'SELECT COUNT(*) as count FROM accounts WHERE email = ?', [email]); + return results.first['count'] as int > 0; + } + + /// Get accounts with pagination and search + @override + Future> searchAccounts({ + String? emailSearch, + String? roleFilter, + int limit = 20, + int offset = 0, + bool orderByCreationDate = true, + }) async { + String countSql; + String dataSql; + List params = []; + + // Build hardcoded SQL based on filter combinations + if (emailSearch != null && + emailSearch.isNotEmpty && + roleFilter != null && + roleFilter.isNotEmpty) { + // Both email and role filters + countSql = + 'SELECT COUNT(*) as count FROM accounts WHERE email LIKE ? AND roles LIKE ?'; + if (orderByCreationDate) { + dataSql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email LIKE ? AND roles LIKE ? ORDER BY creationDate DESC LIMIT ? OFFSET ?'; + } else { + dataSql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email LIKE ? AND roles LIKE ? ORDER BY email ASC LIMIT ? OFFSET ?'; + } + params = ['%$emailSearch%', '%$roleFilter%', limit, offset]; + } else if (emailSearch != null && emailSearch.isNotEmpty) { + // Only email filter + countSql = 'SELECT COUNT(*) as count FROM accounts WHERE email LIKE ?'; + if (orderByCreationDate) { + dataSql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email LIKE ? ORDER BY creationDate DESC LIMIT ? OFFSET ?'; + } else { + dataSql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email LIKE ? ORDER BY email ASC LIMIT ? OFFSET ?'; + } + params = ['%$emailSearch%', limit, offset]; + } else if (roleFilter != null && roleFilter.isNotEmpty) { + // Only role filter + countSql = 'SELECT COUNT(*) as count FROM accounts WHERE roles LIKE ?'; + if (orderByCreationDate) { + dataSql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE roles LIKE ? ORDER BY creationDate DESC LIMIT ? OFFSET ?'; + } else { + dataSql = + 'SELECT id, email, password, creationDate, roles FROM accounts WHERE roles LIKE ? ORDER BY email ASC LIMIT ? OFFSET ?'; + } + params = ['%$roleFilter%', limit, offset]; + } else { + // No filters + countSql = 'SELECT COUNT(*) as count FROM accounts'; + if (orderByCreationDate) { + dataSql = + 'SELECT id, email, password, creationDate, roles FROM accounts ORDER BY creationDate DESC LIMIT ? OFFSET ?'; + } else { + dataSql = + 'SELECT id, email, password, creationDate, roles FROM accounts ORDER BY email ASC LIMIT ? OFFSET ?'; + } + params = [limit, offset]; + } + + // Get total count (use appropriate parameters for count query) + final countParams = params + .take(params.length - 2) + .toList(); // Remove limit and offset for count + final countResults = database.select(countSql, countParams); + final totalCount = countResults.first['count'] as int; + + // Get data + final dataResults = database.select(dataSql, params); + final items = dataResults.map((row) => fromMap(row)).toList(); + + return PagedResult( + items: items, + totalCount: totalCount, + limit: limit, + offset: offset, + hasNext: (offset + limit) < totalCount, + hasPrevious: offset > 0, + ); + } + + /// Get account statistics + @override + Future> getAccountStats() async { + // Total count + final totalResults = + database.select('SELECT COUNT(*) as count FROM accounts'); + final totalCount = totalResults.first['count'] as int; + + // Recent count (last 30 days) + final thirtyDaysAgo = DateTime.now().subtract(const Duration(days: 30)); + final recentResults = database.select( + 'SELECT COUNT(*) as count FROM accounts WHERE creationDate >= ?', + [thirtyDaysAgo.toUtc().toIso8601String()]); + final recentCount = recentResults.first['count'] as int; + + // Role distribution + final allAccounts = await findAll(); + final roleDistribution = {}; + for (final account in allAccounts) { + for (final role in account.roles) { + roleDistribution[role] = (roleDistribution[role] ?? 0) + 1; + } + } + + return { + 'totalAccounts': totalCount, + 'recentAccounts': recentCount, + 'roleDistribution': roleDistribution, + }; + } + + @override + Future deleteAllById(List ids) async { + if (ids.isEmpty) return 0; + + // Create placeholders for the IN clause + final placeholders = List.filled(ids.length, '?').join(','); + final sql = 'DELETE FROM accounts WHERE id IN ($placeholders)'; + + // Count existing records first + final countSql = + 'SELECT COUNT(*) as count FROM accounts WHERE id IN ($placeholders)'; + final countResult = database.select(countSql, ids); + final existingCount = countResult.first['count'] as int; + + // Execute the delete query + database.execute(sql, ids); + return existingCount; + } +} diff --git a/lib/src/services/common/repositories/crud_repository.dart b/lib/src/services/common/repositories/crud_repository.dart new file mode 100644 index 0000000..a87b8ae --- /dev/null +++ b/lib/src/services/common/repositories/crud_repository.dart @@ -0,0 +1,194 @@ +import 'dart:async'; + +/// Base interface for entities that can be stored in a repository +abstract class Entity { + /// Unique identifier for the entity + dynamic get id; +} + +/// Query parameters for filtering and pagination +class QueryParams { + final Map filters; + final List orderBy; + final int? limit; + final int? offset; + final bool ascending; + + const QueryParams({ + this.filters = const {}, + this.orderBy = const [], + this.limit, + this.offset, + this.ascending = true, + }); + + QueryParams copyWith({ + Map? filters, + List? orderBy, + int? limit, + int? offset, + bool? ascending, + }) { + return QueryParams( + filters: filters ?? this.filters, + orderBy: orderBy ?? this.orderBy, + limit: limit ?? this.limit, + offset: offset ?? this.offset, + ascending: ascending ?? this.ascending, + ); + } +} + +/// Result wrapper for paginated queries +class PagedResult { + final List items; + final int totalCount; + final int? limit; + final int? offset; + final bool hasNext; + final bool hasPrevious; + + const PagedResult({ + required this.items, + required this.totalCount, + this.limit, + this.offset, + required this.hasNext, + required this.hasPrevious, + }); +} + +/// Exception thrown when an entity is not found +class EntityNotFoundException implements Exception { + final String message; + final dynamic id; + + const EntityNotFoundException(this.message, this.id); + + @override + String toString() => 'EntityNotFoundException: $message (ID: $id)'; +} + +/// Exception thrown when a create operation fails due to constraint violation +class EntityConstraintException implements Exception { + final String message; + final String constraint; + + const EntityConstraintException(this.message, this.constraint); + + @override + String toString() => 'EntityConstraintException: $message ($constraint)'; +} + +/// Exception thrown when repository operations fail +class RepositoryException implements Exception { + final String message; + final Exception? cause; + + const RepositoryException(this.message, [this.cause]); + + @override + String toString() => + 'RepositoryException: $message${cause != null ? ' (Caused by: $cause)' : ''}'; +} + +/// Abstract base repository providing CRUD operations for entities +abstract class CrudRepository { + /// Creates a new entity in the repository + /// Returns the created entity with populated ID and metadata + FutureOr create(T entity); + + /// Creates multiple entities in a single transaction + /// Returns the list of created entities with populated IDs + FutureOr> createAll(List entities); + + /// Finds an entity by its unique identifier + /// Returns null if not found + FutureOr findById(ID id); + + /// Finds an entity by its unique identifier + /// Throws EntityNotFoundException if not found + FutureOr getById(ID id); + + /// Finds all entities matching the given query parameters + FutureOr> findAll([QueryParams? params]); + + /// Finds entities with pagination support + FutureOr> findPage(QueryParams params); + + /// Finds the first entity matching the query parameters + /// Returns null if not found + FutureOr findFirst(QueryParams params); + + /// Finds a single entity matching the query parameters + /// Throws EntityNotFoundException if not found or multiple entities found + FutureOr findOne(QueryParams params); + + /// Checks if an entity with the given ID exists + FutureOr existsById(ID id); + + /// Counts entities matching the query parameters + FutureOr count([QueryParams? params]); + + /// Updates an existing entity + /// Returns the updated entity + /// Throws EntityNotFoundException if entity doesn't exist + FutureOr update(T entity); + + /// Updates multiple entities in a single transaction + /// Returns the list of updated entities + FutureOr> updateAll(List entities); + + /// Partially updates an entity with the given fields + /// Returns the updated entity + /// Throws EntityNotFoundException if entity doesn't exist + FutureOr updatePartial(ID id, Map fields); + + /// Deletes an entity by its ID + /// Returns true if entity was deleted, false if it didn't exist + FutureOr deleteById(ID id); + + /// Deletes an entity + /// Returns true if entity was deleted, false if it didn't exist + FutureOr delete(T entity); + + /// Deletes multiple entities by their IDs + /// Returns the number of entities actually deleted + FutureOr deleteAllById(List ids); + + /// Deletes entities matching the query parameters + /// Returns the number of entities deleted + FutureOr deleteWhere(QueryParams params); + + /// Deletes all entities in the repository + /// Returns the number of entities deleted + FutureOr deleteAll(); + + /// Saves an entity (create if new, update if exists) + /// Determines operation based on entity ID + FutureOr save(T entity); + + /// Saves multiple entities (create new ones, update existing) + FutureOr> saveAll(List entities); +} + +/// Extended repository interface with advanced querying capabilities +abstract class AdvancedCrudRepository + extends CrudRepository { + /// Executes a custom query and returns raw results + FutureOr>> executeQuery(String query, + [List? parameters]); + + /// Executes a custom update/delete command + /// Returns the number of affected rows + FutureOr executeCommand(String command, [List? parameters]); + + /// Finds entities using a custom where clause + FutureOr> findWhere(String whereClause, [List? parameters]); + + /// Performs a batch operation within a transaction + FutureOr transaction(FutureOr Function() operation); + + /// Refreshes/reloads an entity from the data source + FutureOr refresh(T entity); +} diff --git a/lib/src/services/common/repositories/sqlite3.dart b/lib/src/services/common/repositories/sqlite3.dart deleted file mode 100644 index ccf4b0e..0000000 --- a/lib/src/services/common/repositories/sqlite3.dart +++ /dev/null @@ -1,39 +0,0 @@ -import 'package:quiver/core.dart'; -import 'package:top_shelf/src/services/accounts/models/account.dart'; -import 'package:sqlite3/sqlite3.dart'; -import 'package:top_shelf/src/services/common/repositories/abstract.dart'; - -class SqliteAccountRepository implements AAccountsRepository { - final Database database; - - SqliteAccountRepository(this.database); - - @override - Account create( - final String email, final String password, final List roles) { - final results = database.select( - 'INSERT INTO accounts (email, password, creationDate, roles) VALUES (?, ?, ?, ?) RETURNING id, email, password, creationDate, roles', - [ - email, - password, - DateTime.now().toUtc().toIso8601String(), - roles.join(','), - ], - ); - return results.map((e) => Account.fromJson(e)).toList().first; - } - - @override - Optional findAccountByEmail(String email) { - final results = database.select( - 'SELECT id, email, password, creationDate, roles FROM accounts WHERE email = ?', - [ - email, - ], - ); - return switch (results.isEmpty) { - true => Optional.absent(), - false => Optional.of(Account.fromJson(results.first)) - }; - } -} diff --git a/lib/src/services/common/repositories/sqlite_crud_repository.dart b/lib/src/services/common/repositories/sqlite_crud_repository.dart new file mode 100644 index 0000000..e418a95 --- /dev/null +++ b/lib/src/services/common/repositories/sqlite_crud_repository.dart @@ -0,0 +1,334 @@ +import 'dart:async'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:top_shelf/src/services/common/repositories/crud_repository.dart'; + +/// SQLite implementation of the CRUD repository using secure hardcoded SQL patterns +abstract class SqliteCrudRepository + implements AdvancedCrudRepository { + final Database database; + + SqliteCrudRepository(this.database); + + /// Converts a Map from SQLite to an entity instance + T fromMap(Map map); + + /// Converts an entity to a Map for SQLite operations + Map toMap(T entity); + + /// Returns hardcoded SQL for creating an entity + String get createSql; + + /// Returns hardcoded SQL for finding by ID + String get findByIdSql; + + /// Returns hardcoded SQL for finding all entities + String get findAllSql; + + /// Returns hardcoded SQL for counting entities + String get countSql; + + /// Returns hardcoded SQL for updating an entity by ID + String get updateByIdSql; + + /// Returns hardcoded SQL for deleting by ID + String get deleteByIdSql; + + /// Returns the list of column values for insert operations + List getInsertValues(T entity); + + /// Returns the list of column values for update operations + List getUpdateValues(T entity); + + @override + Future create(T entity) async { + try { + final values = getInsertValues(entity); + final results = database.select(createSql, values); + if (results.isEmpty) { + throw RepositoryException('Failed to create entity'); + } + return fromMap(results.first); + } catch (e) { + if (e is SqliteException) { + if (e.extendedResultCode == 2067) { + throw EntityConstraintException( + 'Unique constraint violation', e.message); + } + } + throw RepositoryException('Error creating entity', + e is Exception ? e : Exception(e.toString())); + } + } + + @override + Future> createAll(List entities) async { + return await transaction(() async { + final results = []; + for (final entity in entities) { + results.add(await create(entity)); + } + return results; + }); + } + + @override + Future findById(ID id) async { + try { + final results = database.select(findByIdSql, [id]); + return results.isEmpty ? null : fromMap(results.first); + } catch (e) { + throw RepositoryException('Error finding entity by ID', + e is Exception ? e : Exception(e.toString())); + } + } + + @override + Future getById(ID id) async { + final result = await findById(id); + if (result == null) { + throw EntityNotFoundException('Entity not found', id); + } + return result; + } + + @override + Future> findAll([QueryParams? params]) async { + try { + // For simple findAll without complex filtering, use the basic SQL + if (params == null || + (params.filters.isEmpty && + params.orderBy.isEmpty && + params.limit == null)) { + final results = database.select(findAllSql); + return results.map((row) => fromMap(row)).toList(); + } + + // For complex queries, delegate to subclass implementation + return await findWithParams(params); + } catch (e) { + throw RepositoryException('Error finding entities', + e is Exception ? e : Exception(e.toString())); + } + } + + /// Subclasses must implement this method for complex filtering + /// This ensures all SQL remains hardcoded in specific repositories + Future> findWithParams(QueryParams params); + + @override + Future> findPage(QueryParams params) async { + try { + // Get total count first + final totalCount = await count(QueryParams(filters: params.filters)); + + // Get paginated results + final items = await findWithParams(params); + + final hasNext = params.offset != null && params.limit != null + ? (params.offset! + params.limit!) < totalCount + : false; + + final hasPrevious = params.offset != null ? params.offset! > 0 : false; + + return PagedResult( + items: items, + totalCount: totalCount, + limit: params.limit, + offset: params.offset, + hasNext: hasNext, + hasPrevious: hasPrevious, + ); + } catch (e) { + throw RepositoryException('Error finding paginated entities', + e is Exception ? e : Exception(e.toString())); + } + } + + @override + Future findFirst(QueryParams params) async { + final modifiedParams = params.copyWith(limit: 1); + final results = await findWithParams(modifiedParams); + return results.isEmpty ? null : results.first; + } + + @override + Future findOne(QueryParams params) async { + final results = await findWithParams(params.copyWith(limit: 2)); + if (results.isEmpty) { + throw EntityNotFoundException( + 'No entity found matching criteria', params.filters); + } + if (results.length > 1) { + throw RepositoryException('Multiple entities found when expecting one'); + } + return results.first; + } + + @override + Future existsById(ID id) async { + final result = await findById(id); + return result != null; + } + + @override + Future count([QueryParams? params]) async { + try { + if (params == null || params.filters.isEmpty) { + final results = database.select(countSql); + return results.first['count'] as int; + } + + // For filtered counts, delegate to subclass + return await countWithParams(params); + } catch (e) { + throw RepositoryException('Error counting entities', + e is Exception ? e : Exception(e.toString())); + } + } + + /// Subclasses must implement this method for filtered counting + Future countWithParams(QueryParams params); + + @override + Future update(T entity) async { + try { + final values = getUpdateValues(entity); + final results = database.select(updateByIdSql, values); + if (results.isEmpty) { + throw EntityNotFoundException('Entity not found for update', entity.id); + } + return fromMap(results.first); + } catch (e) { + if (e is EntityNotFoundException) rethrow; + throw RepositoryException('Error updating entity', + e is Exception ? e : Exception(e.toString())); + } + } + + @override + Future> updateAll(List entities) async { + return await transaction(() async { + final results = []; + for (final entity in entities) { + results.add(await update(entity)); + } + return results; + }); + } + + @override + Future updatePartial(ID id, Map fields) async { + // Subclasses must implement specific update methods for different field combinations + throw UnimplementedError( + 'Subclasses must implement updatePartial with specific SQL for each field combination'); + } + + @override + Future deleteById(ID id) async { + try { + database.execute(deleteByIdSql, [id]); + return database.updatedRows > 0; + } catch (e) { + throw RepositoryException('Error deleting entity by ID', + e is Exception ? e : Exception(e.toString())); + } + } + + @override + Future delete(T entity) async { + return await deleteById(entity.id); + } + + @override + Future deleteAllById(List ids) async { + if (ids.isEmpty) return 0; + + // Subclasses must implement this with hardcoded SQL for batch deletes + throw UnimplementedError( + 'Subclasses must implement deleteAllById with specific SQL'); + } + + @override + Future deleteWhere(QueryParams params) async { + // Subclasses must implement this with hardcoded SQL for conditional deletes + throw UnimplementedError( + 'Subclasses must implement deleteWhere with specific SQL'); + } + + @override + Future deleteAll() async { + // Subclasses must implement this with hardcoded SQL + throw UnimplementedError( + 'Subclasses must implement deleteAll with specific SQL'); + } + + @override + Future save(T entity) async { + if (entity.id == null) { + return await create(entity); + } else { + final exists = await existsById(entity.id); + return exists ? await update(entity) : await create(entity); + } + } + + @override + Future> saveAll(List entities) async { + return await transaction(() async { + final results = []; + for (final entity in entities) { + results.add(await save(entity)); + } + return results; + }); + } + + @override + Future>> executeQuery(String query, + [List? parameters]) async { + try { + return database.select(query, parameters ?? []); + } catch (e) { + throw RepositoryException('Error executing custom query', + e is Exception ? e : Exception(e.toString())); + } + } + + @override + Future executeCommand(String command, + [List? parameters]) async { + try { + database.execute(command, parameters ?? []); + return database.updatedRows; + } catch (e) { + throw RepositoryException('Error executing custom command', + e is Exception ? e : Exception(e.toString())); + } + } + + @override + Future> findWhere(String whereClause, + [List? parameters]) async { + // This method should only be used by subclasses with hardcoded WHERE clauses + throw UnimplementedError( + 'Subclasses must implement findWhere with specific hardcoded SQL'); + } + + @override + Future transaction(FutureOr Function() operation) async { + database.execute('BEGIN TRANSACTION'); + try { + final result = await operation(); + database.execute('COMMIT'); + return result; + } catch (e) { + database.execute('ROLLBACK'); + rethrow; + } + } + + @override + Future refresh(T entity) async { + return await getById(entity.id); + } +} diff --git a/lib/src/services/common/services/account/account_service.dart b/lib/src/services/common/services/account/account_service.dart new file mode 100644 index 0000000..1af04a7 --- /dev/null +++ b/lib/src/services/common/services/account/account_service.dart @@ -0,0 +1,420 @@ +import 'dart:async'; +import 'package:logging/logging.dart'; +import 'package:pbkdf2/pbkdf2.dart'; +import 'package:top_shelf/src/services/accounts/models/account.dart'; +import 'package:top_shelf/src/services/authentication/models/tokens.dart'; +import 'package:top_shelf/src/services/common/pepper_factory.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_repository_interface.dart'; +import 'package:top_shelf/src/services/common/services/account/account_service_interface.dart'; +import 'package:top_shelf/src/services/common/services/base_crud_service.dart'; +import 'package:top_shelf/src/services/common/repositories/crud_repository.dart'; +import 'package:top_shelf/src/internal/jwt.dart'; + +final _logger = Logger('AccountService'); + +class AccountService extends AbstractBaseService + implements AccountServiceInterface { + final AccountRepositoryInterface _accountRepository; + final String Function() _pepperFactory; + final String Function() _jwtSecretFactory; + + AccountService( + this._accountRepository, { + String Function()? pepperFactory, + String Function()? jwtSecretFactory, + Logger? logger, + }) : _pepperFactory = pepperFactory ?? defaultPepperFactory, + _jwtSecretFactory = jwtSecretFactory ?? defaultJwtSecretKeyFactory, + super(_accountRepository); + + @override + AccountRepositoryInterface get repository => _accountRepository; + + /// Creates a new account with email and password + @override + Future createAccount({ + required String email, + required String password, + List roles = const ['user'], + }) async { + _logger.fine('Attempting to create account for email: $email'); + // Validate email format + final emailValidation = _validateEmail(email); + if (!emailValidation.isValid) { + throw ServiceValidationException(emailValidation.errors); + } + + // Validate password strength + final passwordValidation = _validatePassword(password); + if (!passwordValidation.isValid) { + throw ServiceValidationException(passwordValidation.errors); + } + + // Check if email is already taken + if (await _accountRepository.isEmailTaken(email)) { + throw ServiceValidationException(['Email is already taken']); + } + + // Hash the password + final hashedPassword = _hashPassword(password); + + try { + final account = await _accountRepository.createAccountWithDefaults( + email, + hashedPassword, + roles: roles, + ); + _logger.fine( + 'Account created successfully: ID ${account.id}, Email: ${account.email}'); + return account; + } on EntityConstraintException catch (e) { + _logger.severe( + 'Account creation failed - constraint violation: ${e.message}'); + throw ServiceValidationException([e.message]); + } on RepositoryException catch (e) { + _logger + .severe('Account creation failed - repository error: ${e.message}'); + throw ServiceException('Failed to create account', e); + } + } + + /// Authenticates user with email and password + @override + Future authenticate(String email, String password) async { + _logger.fine('Authentication attempt for email: $email'); + final account = await _accountRepository.findByEmail(email); + if (account == null) { + _logger.warning( + 'Authentication failed - account not found for email: $email'); + return null; + } + + final pbkdf2 = Pbkdf2(pepperFactory: _pepperFactory); + final isValidPassword = pbkdf2.verify(password, account.password); + if (isValidPassword) { + _logger.info( + 'Authentication successful for email: $email, ID: ${account.id}'); + return account; + } else { + _logger.warning( + 'Authentication failed - invalid password for email: $email'); + return null; + } + } + + /// Generates JWT tokens for an authenticated account + @override + Future login(Account account) async { + _logger.fine('Generating login tokens for account ID: ${account.id}'); + + // Create access token (short-lived) + var jwt = JsonWebToken(secretKeyFactory: _jwtSecretFactory); + jwt.createPayload(account.id.toString()); + final accessToken = jwt.sign(); + + // Create refresh token (long-lived) + jwt = JsonWebToken(secretKeyFactory: _jwtSecretFactory); + jwt.createPayload(account.id.toString(), expireIn: Duration(days: 30)); + final refreshToken = jwt.sign(); + + _logger.info( + 'Login tokens generated successfully for account ID: ${account.id}'); + return Tokens(accessToken, refreshToken); + } + + /// Refreshes JWT tokens using a valid refresh token + @override + Future refreshTokens(String refreshToken) async { + _logger.fine('Attempting to refresh tokens'); + try { + // Parse and validate the refresh token + final jwt = + JsonWebToken.parse(refreshToken, secretKeyFactory: _jwtSecretFactory); + if (!jwt.verify()) { + _logger + .warning('Token refresh failed - invalid or expired refresh token'); + throw ServiceValidationException(['Invalid or expired refresh token']); + } + + // Extract user ID from the refresh token + final userId = jwt.sub; + if (userId == null || userId.isEmpty) { + _logger + .severe('Token refresh failed - missing user ID in refresh token'); + throw ServiceValidationException( + ['Invalid refresh token: missing user ID']); + } + + // Verify the account still exists + final accountId = int.tryParse(userId); + if (accountId == null) { + _logger + .severe('Token refresh failed - invalid user ID format: $userId'); + throw ServiceValidationException( + ['Invalid refresh token: invalid user ID format']); + } + + final account = await _accountRepository.findById(accountId); + if (account == null) { + _logger.warning( + 'Token refresh failed - account not found for ID: $accountId'); + throw ServiceNotFoundException('Account not found', accountId); + } + + // Generate new tokens + var newJwt = JsonWebToken(secretKeyFactory: _jwtSecretFactory); + newJwt.createPayload(userId); + final accessToken = newJwt.sign(); + + newJwt = JsonWebToken(secretKeyFactory: _jwtSecretFactory); + newJwt.createPayload(userId, expireIn: Duration(days: 30)); + final newRefreshToken = newJwt.sign(); + + _logger.info('Token refresh successful for account ID: $accountId'); + return Tokens(accessToken, newRefreshToken); + } catch (e) { + if (e is ServiceValidationException || e is ServiceNotFoundException) { + rethrow; + } + // Handle JWT parsing/verification errors + _logger + .severe('Token refresh failed - JWT parsing/verification error: $e'); + throw ServiceValidationException(['Invalid or expired refresh token']); + } + } + + /// Changes user password + @override + Future changePassword( + int accountId, String currentPassword, String newPassword) async { + try { + final account = await getById(accountId); + + // Verify current password + final pbkdf2 = Pbkdf2(pepperFactory: _pepperFactory); + if (!pbkdf2.verify(currentPassword, account.password)) { + throw ServiceValidationException(['Current password is incorrect']); + } + + // Validate new password + final passwordValidation = _validatePassword(newPassword); + if (!passwordValidation.isValid) { + throw ServiceValidationException(passwordValidation.errors); + } + + final hashedNewPassword = _hashPassword(newPassword); + return await _accountRepository.updatePassword( + accountId, hashedNewPassword); + } on EntityNotFoundException catch (_) { + throw ServiceNotFoundException('Account not found', accountId); + } on RepositoryException catch (e) { + throw ServiceException('Failed to change password', e); + } + } + + /// Adds a role to an account + @override + Future addRole(int accountId, String role) async { + _validateRole(role); + try { + return await _accountRepository.addRole(accountId, role); + } on EntityNotFoundException catch (_) { + throw ServiceNotFoundException('Account not found', accountId); + } on RepositoryException catch (e) { + throw ServiceException('Failed to add role', e); + } + } + + /// Removes a role from an account + @override + Future removeRole(int accountId, String role) async { + return await _accountRepository.removeRole(accountId, role); + } + + /// Searches accounts with pagination + @override + Future> searchAccounts({ + String? emailSearch, + String? roleFilter, + int limit = 20, + int offset = 0, + bool orderByCreationDate = true, + }) async { + return await _accountRepository.searchAccounts( + emailSearch: emailSearch, + roleFilter: roleFilter, + limit: limit, + offset: offset, + orderByCreationDate: orderByCreationDate, + ); + } + + /// Gets account statistics + @override + Future> getAccountStatistics() async { + return await _accountRepository.getAccountStats(); + } + + /// Finds accounts by role + @override + Future> findAccountsByRole(String role) async { + return await _accountRepository.findByRole(role); + } + + /// Finds accounts created within date range + @override + Future> findAccountsByDateRange( + DateTime startDate, DateTime endDate) async { + return await _accountRepository.findByDateRange(startDate, endDate); + } + + @override + Future validate(Account entity) async { + final errors = []; + + // Validate email + final emailValidation = _validateEmail(entity.email); + if (!emailValidation.isValid) { + errors.addAll(emailValidation.errors); + } + + // Validate roles + for (final role in entity.roles) { + try { + _validateRole(role); + } catch (e) { + errors.add('Invalid role: $role'); + } + } + + // Check if email is unique (for new accounts) + if (entity.id == 0) { + if (await _accountRepository.isEmailTaken(entity.email)) { + errors.add('Email is already taken'); + } + } + + return errors.isEmpty + ? ValidationResult.success() + : ValidationResult.failure(errors); + } + + @override + Future validatePartialUpdate( + Map fields) async { + final errors = []; + + if (fields.containsKey('email')) { + final emailValidation = _validateEmail(fields['email'] as String); + if (!emailValidation.isValid) { + errors.addAll(emailValidation.errors); + } + } + + if (fields.containsKey('password')) { + final passwordValidation = + _validatePassword(fields['password'] as String); + if (!passwordValidation.isValid) { + errors.addAll(passwordValidation.errors); + } + } + + if (fields.containsKey('roles')) { + final roles = fields['roles'] as String; + for (final role in roles.split(',')) { + try { + _validateRole(role.trim()); + } catch (e) { + errors.add('Invalid role: $role'); + } + } + } + + return errors.isEmpty + ? ValidationResult.success() + : ValidationResult.failure(errors); + } + + @override + Future beforeCreate(Account entity) async { + // Log account creation attempt + _logger.info('Creating account for email: ${entity.email}'); + } + + @override + Future afterCreate(Account entity) async { + // Log successful account creation + _logger.info( + 'Account created successfully: ID ${entity.id}, Email: ${entity.email}'); + } + + @override + Future beforeDelete(Account entity) async { + // Log account deletion + _logger.info('Deleting account: ID ${entity.id}, Email: ${entity.email}'); + } + + @override + Future afterDelete(Account entity) async { + // Log successful account deletion + _logger.info('Account deleted successfully: ID ${entity.id}'); + } + + // Private helper methods + + String _hashPassword(String password) { + final pbkdf2 = Pbkdf2(pepperFactory: _pepperFactory); + final hashedPassword = pbkdf2.hash(password); + return hashedPassword; + } + + ValidationResult _validateEmail(String email) { + final errors = []; + + if (email.isEmpty) { + errors.add('Email is required'); + } else { + final emailRegex = RegExp(r'^[\w\.-]+@[\w\.-]+\.\w+$'); + if (!emailRegex.hasMatch(email)) { + errors.add('Invalid email format'); + } + } + + return errors.isEmpty + ? ValidationResult.success() + : ValidationResult.failure(errors); + } + + ValidationResult _validatePassword(String password) { + final errors = []; + + if (password.isEmpty) { + errors.add('Password is required'); + } else { + if (password.length < 8) { + errors.add('Password must be at least 8 characters long'); + } + if (!RegExp(r'[A-Z]').hasMatch(password)) { + errors.add('Password must contain at least one uppercase letter'); + } + if (!RegExp(r'[a-z]').hasMatch(password)) { + errors.add('Password must contain at least one lowercase letter'); + } + if (!RegExp(r'[0-9]').hasMatch(password)) { + errors.add('Password must contain at least one number'); + } + } + + return errors.isEmpty + ? ValidationResult.success() + : ValidationResult.failure(errors); + } + + void _validateRole(String role) { + const validRoles = ['admin', 'user', 'moderator', 'guest']; + if (!validRoles.contains(role.toLowerCase())) { + throw ArgumentError( + 'Invalid role: $role. Valid roles are: ${validRoles.join(', ')}'); + } + } +} diff --git a/lib/src/services/common/services/account/account_service_interface.dart b/lib/src/services/common/services/account/account_service_interface.dart new file mode 100644 index 0000000..895514c --- /dev/null +++ b/lib/src/services/common/services/account/account_service_interface.dart @@ -0,0 +1,65 @@ +import 'dart:async'; +import 'package:top_shelf/src/services/accounts/models/account.dart'; +import 'package:top_shelf/src/services/authentication/models/tokens.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_repository_interface.dart'; +import 'package:top_shelf/src/services/common/repositories/crud_repository.dart'; +import 'package:top_shelf/src/services/common/services/base_crud_service.dart'; + +/// Abstract interface for account service operations +/// This allows different implementations of account services for various databases +abstract class AccountServiceInterface implements BaseService { + /// Gets the underlying repository + @override + AccountRepositoryInterface get repository; + + /// Creates a new account with email and password + Future createAccount({ + required String email, + required String password, + List roles = const ['user'], + }); + + /// Authenticates user with email and password + /// Returns the account if authentication is successful, null otherwise + Future authenticate(String email, String password); + + /// Generates JWT tokens for an authenticated account + Future login(Account account); + + /// Refreshes JWT tokens using a valid refresh token + Future refreshTokens(String refreshToken); + + /// Changes user password after verifying current password + Future changePassword( + int accountId, + String currentPassword, + String newPassword, + ); + + /// Adds a role to an account + Future addRole(int accountId, String role); + + /// Removes a role from an account + Future removeRole(int accountId, String role); + + /// Searches accounts with pagination and filtering + Future> searchAccounts({ + String? emailSearch, + String? roleFilter, + int limit = 20, + int offset = 0, + bool orderByCreationDate = true, + }); + + /// Gets account statistics + Future> getAccountStatistics(); + + /// Finds accounts by role + Future> findAccountsByRole(String role); + + /// Finds accounts created within date range + Future> findAccountsByDateRange( + DateTime startDate, + DateTime endDate, + ); +} diff --git a/lib/src/services/common/services/base_crud_service.dart b/lib/src/services/common/services/base_crud_service.dart new file mode 100644 index 0000000..0d1bcea --- /dev/null +++ b/lib/src/services/common/services/base_crud_service.dart @@ -0,0 +1,228 @@ +import 'dart:async'; +import 'package:top_shelf/src/services/common/repositories/crud_repository.dart'; + +/// Base service interface providing common business logic operations +abstract class BaseService { + /// Gets the underlying repository + CrudRepository get repository; + + /// Creates a new entity with validation + FutureOr create(T entity); + + /// Creates multiple entities with validation + FutureOr> createAll(List entities); + + /// Finds an entity by ID + FutureOr findById(ID id); + + /// Gets an entity by ID or throws exception + FutureOr getById(ID id); + + /// Finds all entities with optional filtering + FutureOr> findAll([QueryParams? params]); + + /// Finds entities with pagination + FutureOr> findPage(QueryParams params); + + /// Updates an entity with validation + FutureOr update(T entity); + + /// Partially updates an entity + FutureOr updatePartial(ID id, Map fields); + + /// Deletes an entity by ID + FutureOr deleteById(ID id); + + /// Checks if entity exists + FutureOr exists(ID id); + + /// Counts entities + FutureOr count([QueryParams? params]); + + /// Validates entity before operations + FutureOr validate(T entity); + + /// Validates partial update fields + FutureOr validatePartialUpdate(Map fields); +} + +/// Default implementation of BaseService +abstract class AbstractBaseService + implements BaseService { + @override + final CrudRepository repository; + + AbstractBaseService(this.repository); + + @override + Future create(T entity) async { + final validation = await validate(entity); + if (!validation.isValid) { + throw ServiceValidationException(validation.errors); + } + + await beforeCreate(entity); + final result = await repository.create(entity); + await afterCreate(result); + return result; + } + + @override + Future> createAll(List entities) async { + for (final entity in entities) { + final validation = await validate(entity); + if (!validation.isValid) { + throw ServiceValidationException(validation.errors); + } + } + + await beforeCreateAll(entities); + final results = await repository.createAll(entities); + await afterCreateAll(results); + return results; + } + + @override + Future findById(ID id) async { + return await repository.findById(id); + } + + @override + Future getById(ID id) async { + final result = await repository.findById(id); + if (result == null) { + throw ServiceNotFoundException('Entity not found', id); + } + return result; + } + + @override + Future> findAll([QueryParams? params]) async { + return await repository.findAll(params); + } + + @override + Future> findPage(QueryParams params) async { + return await repository.findPage(params); + } + + @override + Future update(T entity) async { + final validation = await validate(entity); + if (!validation.isValid) { + throw ServiceValidationException(validation.errors); + } + + await beforeUpdate(entity); + final result = await repository.update(entity); + await afterUpdate(result); + return result; + } + + @override + Future updatePartial(ID id, Map fields) async { + final validation = await validatePartialUpdate(fields); + if (!validation.isValid) { + throw ServiceValidationException(validation.errors); + } + + await beforePartialUpdate(id, fields); + final result = await repository.updatePartial(id, fields); + await afterPartialUpdate(result); + return result; + } + + @override + Future deleteById(ID id) async { + final entity = await findById(id); + if (entity == null) { + return false; + } + + await beforeDelete(entity); + final result = await repository.deleteById(id); + if (result) { + await afterDelete(entity); + } + return result; + } + + @override + Future exists(ID id) async { + return await repository.existsById(id); + } + + @override + Future count([QueryParams? params]) async { + return await repository.count(params); + } + + @override + Future validate(T entity) async { + // Default implementation - override in concrete services + return ValidationResult.success(); + } + + @override + Future validatePartialUpdate( + Map fields) async { + // Default implementation - override in concrete services + return ValidationResult.success(); + } + + // Lifecycle hooks - override in concrete services + Future beforeCreate(T entity) async {} + Future afterCreate(T entity) async {} + Future beforeCreateAll(List entities) async {} + Future afterCreateAll(List entities) async {} + Future beforeUpdate(T entity) async {} + Future afterUpdate(T entity) async {} + Future beforePartialUpdate(ID id, Map fields) async {} + Future afterPartialUpdate(T entity) async {} + Future beforeDelete(T entity) async {} + Future afterDelete(T entity) async {} +} + +/// Validation result for service operations +class ValidationResult { + final bool isValid; + final List errors; + + ValidationResult.success() + : isValid = true, + errors = []; + ValidationResult.failure(this.errors) : isValid = false; +} + +/// Exception thrown when service validation fails +class ServiceValidationException implements Exception { + final List errors; + + const ServiceValidationException(this.errors); + + @override + String toString() => 'ServiceValidationException: ${errors.join(', ')}'; +} + +/// Exception thrown when service entity is not found +class ServiceNotFoundException implements Exception { + final String message; + final dynamic id; + + const ServiceNotFoundException(this.message, this.id); + + @override + String toString() => 'ServiceNotFoundException: $message (ID: $id)'; +} + +/// Exception thrown when service operations fail +class ServiceException implements Exception { + final String message; + final Exception? cause; + + const ServiceException(this.message, [this.cause]); + + @override + String toString() => + 'ServiceException: $message${cause != null ? ' (Caused by: $cause)' : ''}'; +} diff --git a/lib/top_shelf.dart b/lib/top_shelf.dart index f9ff76f..575775b 100644 --- a/lib/top_shelf.dart +++ b/lib/top_shelf.dart @@ -22,7 +22,16 @@ export 'package:top_shelf/src/middlewares/rate_limiter.dart'; export 'package:top_shelf/src/middlewares/response_cache.dart'; export 'package:top_shelf/src/middlewares/session_manager.dart'; +export 'package:top_shelf/src/services/common/repositories/crud_repository.dart'; +export 'package:top_shelf/src/services/common/repositories/sqlite_crud_repository.dart'; + +export 'package:top_shelf/src/services/common/repositories/account/account_repository_interface.dart'; +export 'package:top_shelf/src/services/common/repositories/account/account_sqlite_repository.dart'; +export 'package:top_shelf/src/services/common/repositories/account/account_memory_repository.dart'; + +export 'package:top_shelf/src/services/common/services/base_crud_service.dart'; +export 'package:top_shelf/src/services/common/services/account/account_service_interface.dart'; +export 'package:top_shelf/src/services/common/services/account/account_service.dart'; + export 'package:top_shelf/src/services/accounts/router/accounts.dart'; export 'package:top_shelf/src/services/authentication/router/authentication.dart'; -export 'package:top_shelf/src/services/common/repositories/abstract.dart'; -export 'package:top_shelf/src/services/common/repositories/sqlite3.dart'; diff --git a/test/services/accounts/account_model_test.dart b/test/services/accounts/account_model_test.dart new file mode 100644 index 0000000..64b4783 --- /dev/null +++ b/test/services/accounts/account_model_test.dart @@ -0,0 +1,448 @@ +import 'package:test/test.dart'; +import 'package:top_shelf/src/services/accounts/models/account.dart'; +import 'package:top_shelf/top_shelf.dart'; + +void main() { + group('Account Model', () { + group('Constructor', () { + test('should create account with all required fields', () { + // Arrange + const id = 1; + const email = 'test@example.com'; + const password = 'hashedpassword'; + final creationDate = DateTime(2023, 12, 25, 10, 30); + final roles = ['user', 'admin']; + + // Act + final account = Account(id, email, password, creationDate, roles); + + // Assert + expect(account.id, equals(id)); + expect(account.email, equals(email)); + expect(account.password, equals(password)); + expect(account.creationDate, equals(creationDate)); + expect(account.roles, equals(roles)); + }); + + test('should create account with single role', () { + // Arrange + const id = 2; + const email = 'user@example.com'; + const password = 'hashedpassword'; + final creationDate = DateTime.now(); + final roles = ['user']; + + // Act + final account = Account(id, email, password, creationDate, roles); + + // Assert + expect(account.roles, equals(['user'])); + expect(account.roles.length, equals(1)); + }); + + test('should create account with empty roles list', () { + // Arrange + const id = 3; + const email = 'empty@example.com'; + const password = 'hashedpassword'; + final creationDate = DateTime.now(); + final roles = []; + + // Act + final account = Account(id, email, password, creationDate, roles); + + // Assert + expect(account.roles, isEmpty); + }); + }); + + group('fromJson', () { + test('should parse JSON with roles as List', () { + // Arrange + final json = { + 'id': 1, + 'email': 'test@example.com', + 'password': 'hashedpassword', + 'creationDate': '2023-12-25T10:30:00.000Z', + 'roles': ['user', 'admin'], + }; + + // Act + final account = Account.fromJson(json); + + // Assert + expect(account.id, equals(1)); + expect(account.email, equals('test@example.com')); + expect(account.password, equals('hashedpassword')); + expect(account.creationDate, + equals(DateTime.parse('2023-12-25T10:30:00.000Z'))); + expect(account.roles, equals(['user', 'admin'])); + }); + + test('should parse JSON with roles as comma-separated string', () { + // Arrange + final json = { + 'id': 2, + 'email': 'user@example.com', + 'password': 'hashedpassword', + 'creationDate': '2023-12-25T15:45:30.123Z', + 'roles': 'user,admin,moderator', + }; + + // Act + final account = Account.fromJson(json); + + // Assert + expect(account.id, equals(2)); + expect(account.email, equals('user@example.com')); + expect(account.password, equals('hashedpassword')); + expect(account.creationDate, + equals(DateTime.parse('2023-12-25T15:45:30.123Z'))); + expect(account.roles, equals(['user', 'admin', 'moderator'])); + }); + + test('should parse JSON with single role as string', () { + // Arrange + final json = { + 'id': 3, + 'email': 'single@example.com', + 'password': 'hashedpassword', + 'creationDate': '2023-12-25T08:00:00.000Z', + 'roles': 'user', + }; + + // Act + final account = Account.fromJson(json); + + // Assert + expect(account.roles, equals(['user'])); + }); + + test('should parse JSON with empty roles string', () { + // Arrange + final json = { + 'id': 4, + 'email': 'empty@example.com', + 'password': 'hashedpassword', + 'creationDate': '2023-12-25T12:00:00.000Z', + 'roles': '', + }; + + // Act + final account = Account.fromJson(json); + + // Assert + expect(account.roles, equals([''])); + }); + + test('should parse JSON with roles containing spaces', () { + // Arrange + final json = { + 'id': 5, + 'email': 'spaces@example.com', + 'password': 'hashedpassword', + 'creationDate': '2023-12-25T16:30:00.000Z', + 'roles': 'user, admin, moderator', + }; + + // Act + final account = Account.fromJson(json); + + // Assert + expect(account.roles, equals(['user', ' admin', ' moderator'])); + }); + + test('should throw FormatException for missing required fields', () { + // Arrange + final json = { + 'id': 1, + 'email': 'test@example.com', + // Missing password, creationDate, and roles + }; + + // Act & Assert + expect( + () => Account.fromJson(json), + throwsA(isA().having( + (e) => e.message, + 'message', + 'Unexpected JSON', + )), + ); + }); + + test('should throw FormatException for invalid field types', () { + // Arrange + final json = { + 'id': 'not_an_int', // Should be int + 'email': 'test@example.com', + 'password': 'hashedpassword', + 'creationDate': '2023-12-25T10:30:00.000Z', + 'roles': ['user'], + }; + + // Act & Assert + expect( + () => Account.fromJson(json), + throwsA(isA()), + ); + }); + + test('should throw FormatException for invalid date format', () { + // Arrange + final json = { + 'id': 1, + 'email': 'test@example.com', + 'password': 'hashedpassword', + 'creationDate': 'invalid-date-format', + 'roles': ['user'], + }; + + // Act & Assert + expect( + () => Account.fromJson(json), + throwsA(isA()), + ); + }); + + test('should handle null values gracefully', () { + // Arrange + final json = { + 'id': 1, + 'email': null, + 'password': 'hashedpassword', + 'creationDate': '2023-12-25T10:30:00.000Z', + 'roles': ['user'], + }; + + // Act & Assert + expect( + () => Account.fromJson(json), + throwsA(isA()), + ); + }); + }); + + group('toJson', () { + test('should convert account to JSON format', () { + // Arrange + final creationDate = DateTime.utc(2023, 12, 25, 10, 30, 45); + final account = Account( + 1, + 'test@example.com', + 'hashedpassword', + creationDate, + ['user', 'admin'], + ); + + // Act + final json = account.toJson(); + + // Assert + expect(json['id'], equals(1)); + expect(json['email'], equals('test@example.com')); + expect(json['creationDate'], equals('2023-12-25T10:30:45.000Z')); + expect(json['roles'], equals(['user', 'admin'])); + expect(json.containsKey('password'), + isFalse); // Password should not be included + }); + + test('should convert UTC dates correctly', () { + // Arrange + final localDate = DateTime(2023, 12, 25, 15, 30); // Local time + final account = Account( + 2, + 'utc@example.com', + 'hashedpassword', + localDate, + ['user'], + ); + + // Act + final json = account.toJson(); + + // Assert + expect(json['creationDate'], contains('T')); + expect(json['creationDate'], endsWith('Z')); + + // Verify it's a valid ISO8601 UTC string + final parsedDate = DateTime.parse(json['creationDate']); + expect(parsedDate.isUtc, isTrue); + }); + + test('should handle empty roles list', () { + // Arrange + final account = Account( + 3, + 'empty@example.com', + 'hashedpassword', + DateTime.utc(2023, 12, 25), + [], + ); + + // Act + final json = account.toJson(); + + // Assert + expect(json['roles'], isEmpty); + expect(json['roles'], isA>()); + }); + + test('should handle single role', () { + // Arrange + final account = Account( + 4, + 'single@example.com', + 'hashedpassword', + DateTime.utc(2023, 12, 25), + ['admin'], + ); + + // Act + final json = account.toJson(); + + // Assert + expect(json['roles'], equals(['admin'])); + }); + + test('should preserve role order', () { + // Arrange + final roles = ['admin', 'user', 'moderator', 'guest']; + final account = Account( + 5, + 'order@example.com', + 'hashedpassword', + DateTime.utc(2023, 12, 25), + roles, + ); + + // Act + final json = account.toJson(); + + // Assert + expect(json['roles'], equals(roles)); + }); + }); + + group('Interface Implementation', () { + test('should implement NetworkObjectToJson interface', () { + // Arrange + final account = Account( + 1, + 'interface@example.com', + 'hashedpassword', + DateTime.now(), + ['user'], + ); + + // Act & Assert + expect(account, isA()); + expect(account.toJson(), isA>()); + }); + + test('should implement Entity interface', () { + // Arrange + final account = Account( + 1, + 'entity@example.com', + 'hashedpassword', + DateTime.now(), + ['user'], + ); + + // Act & Assert + expect(account, isA()); + expect(account.id, isA()); + }); + }); + + group('Equality and Comparison', () { + test('should have proper field access', () { + // Arrange + const id = 1; + const email = 'access@example.com'; + const password = 'hashedpassword'; + final creationDate = DateTime.now(); + final roles = ['user', 'admin']; + + final account = Account(id, email, password, creationDate, roles); + + // Act & Assert + expect(account.id, equals(id)); + expect(account.email, equals(email)); + expect(account.password, equals(password)); + expect(account.creationDate, equals(creationDate)); + expect(account.roles, equals(roles)); + }); + + test('should share reference to roles list', () { + // Arrange + final roles = ['user']; + final account = Account( + 1, + 'reference@example.com', + 'hashedpassword', + DateTime.now(), + roles, + ); + + // Act - Modify the original roles list after account creation + roles.add('admin'); + + // Assert - Account's roles should reflect the change since it shares the reference + expect(account.roles, equals(['user', 'admin'])); + expect(account.roles, contains('admin')); + expect(identical(account.roles, roles), isTrue); + }); + }); + + group('Round-trip Serialization', () { + test( + 'should maintain data integrity through JSON round-trip with List roles', + () { + // Arrange + final originalAccount = Account( + 42, + 'roundtrip@example.com', + 'hashedpassword123', + DateTime.utc(2023, 12, 25, 14, 30, 45), + ['user', 'admin', 'moderator'], + ); + + // Act + final json = originalAccount.toJson(); + json['password'] = + originalAccount.password; // Add password back for round-trip + final deserializedAccount = Account.fromJson(json); + + // Assert + expect(deserializedAccount.id, equals(originalAccount.id)); + expect(deserializedAccount.email, equals(originalAccount.email)); + expect(deserializedAccount.password, equals(originalAccount.password)); + expect(deserializedAccount.creationDate.toUtc(), + equals(originalAccount.creationDate.toUtc())); + expect(deserializedAccount.roles, equals(originalAccount.roles)); + }); + + test('should handle round-trip with string roles format', () { + // Arrange + final json = { + 'id': 99, + 'email': 'string-roles@example.com', + 'password': 'hashedpassword', + 'creationDate': '2023-12-25T10:30:00.000Z', + 'roles': 'user,admin,guest', + }; + + // Act + final account = Account.fromJson(json); + final backToJson = account.toJson(); + + // Assert + expect(account.roles, equals(['user', 'admin', 'guest'])); + expect(backToJson['roles'], equals(['user', 'admin', 'guest'])); + expect(backToJson['roles'], isA>()); + }); + }); + }); +} diff --git a/test/services/accounts/repositories/account_memory_repository_test.dart b/test/services/accounts/repositories/account_memory_repository_test.dart new file mode 100644 index 0000000..08b35f6 --- /dev/null +++ b/test/services/accounts/repositories/account_memory_repository_test.dart @@ -0,0 +1,15 @@ +import 'package:test/test.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_memory_repository.dart'; + +import 'shared_account_repository_tests.dart'; + +void main() { + group('Memory Account Repository Unit Tests', () { + // Run all shared repository tests + runAccountRepositoryTests( + () async { + return AccountMemoryRepository(); + }, + ); + }); +} diff --git a/test/services/accounts/repositories/account_sqlite_repository_test.dart b/test/services/accounts/repositories/account_sqlite_repository_test.dart new file mode 100644 index 0000000..7533c9b --- /dev/null +++ b/test/services/accounts/repositories/account_sqlite_repository_test.dart @@ -0,0 +1,30 @@ +import 'package:test/test.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_sqlite_repository.dart'; +import 'shared_account_repository_tests.dart'; + +void main() { + group('SQLite Account Repository Unit Tests', () { + late Database database; + + // Run all shared repository tests + runAccountRepositoryTests( + () async { + database = sqlite3.openInMemory(); + database.execute(''' + CREATE TABLE accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + creationDate TEXT NOT NULL, + roles TEXT NOT NULL DEFAULT 'user' + ) + '''); + return AccountSqliteRepository(database); + }, + () async { + database.dispose(); + }, + ); + }); +} diff --git a/test/services/accounts/repositories/shared_account_repository_tests.dart b/test/services/accounts/repositories/shared_account_repository_tests.dart new file mode 100644 index 0000000..73c530b --- /dev/null +++ b/test/services/accounts/repositories/shared_account_repository_tests.dart @@ -0,0 +1,398 @@ +import 'package:test/test.dart'; +import 'package:top_shelf/src/services/accounts/models/account.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_repository_interface.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_memory_repository.dart'; +import 'package:top_shelf/src/services/common/repositories/crud_repository.dart'; + +/// Shared unit test suite for repository implementations (pure repository testing) +/// Focuses on data storage, retrieval, and repository-specific operations +void runAccountRepositoryTests( + Future Function() repositoryFactory, + [Future Function()? cleanup]) { + late AccountRepositoryInterface repository; + + setUp(() async { + repository = await repositoryFactory(); + }); + + tearDown(() async { + // Clear repository data if it supports it + if (repository is AccountMemoryRepository) { + (repository as AccountMemoryRepository).clear(); + } + + // Run custom cleanup if provided + if (cleanup != null) { + await cleanup(); + } + }); + + group('Core CRUD Operations', () { + test('should create account with auto-generated ID', () async { + // Arrange + final account = Account( + 0, // Will be auto-generated + 'test@example.com', + 'hashedPassword123', + DateTime.now(), + ['user'], + ); + + // Act + final created = await repository.create(account); + + // Assert + expect(created.id, isPositive); + expect(created.email, equals('test@example.com')); + expect(created.password, equals('hashedPassword123')); + expect(created.roles, equals(['user'])); + }); + + test('should enforce email uniqueness constraint', () async { + // Arrange + final account1 = + Account(0, 'test@example.com', 'pass1', DateTime.now(), ['user']); + final account2 = + Account(0, 'test@example.com', 'pass2', DateTime.now(), ['admin']); + + // Act + await repository.create(account1); + + // Assert - Different implementations may throw different exception types + expect( + () => repository.create(account2), + throwsA(anyOf( + isA(), + isA(), + )), + ); + }); + + test('should retrieve account by ID', () async { + // Arrange + final account = + Account(0, 'retrieve@example.com', 'pass', DateTime.now(), ['user']); + final created = await repository.create(account); + + // Act + final found = await repository.findById(created.id); + + // Assert + expect(found, isNotNull); + expect(found!.id, equals(created.id)); + expect(found.email, equals('retrieve@example.com')); + }); + + test('should return null for non-existent ID', () async { + // Act + final found = await repository.findById(999); + + // Assert + expect(found, isNull); + }); + + test('should update existing account', () async { + // Arrange + final original = + Account(0, 'original@example.com', 'pass', DateTime.now(), ['user']); + final created = await repository.create(original); + + final updated = Account( + created.id, + 'updated@example.com', + 'newPass', + created.creationDate, + ['admin'], + ); + + // Act + final result = await repository.update(updated); + + // Assert + expect(result.id, equals(created.id)); + expect(result.email, equals('updated@example.com')); + expect(result.password, equals('newPass')); + expect(result.roles, equals(['admin'])); + }); + + test('should delete account by ID', () async { + // Arrange + final account = + Account(0, 'delete@example.com', 'pass', DateTime.now(), ['user']); + final created = await repository.create(account); + + // Act + final deleted = await repository.deleteById(created.id); + + // Assert + expect(deleted, isTrue); + expect(await repository.findById(created.id), isNull); + }); + }); + + group('Query Operations', () { + setUp(() async { + // Create test data for querying + await repository.create(Account( + 0, 'alice@example.com', 'pass1', DateTime(2023, 1, 1), ['user'])); + await repository.create(Account( + 0, 'bob@company.com', 'pass2', DateTime(2023, 2, 1), ['admin'])); + await repository.create(Account(0, 'charlie@example.com', 'pass3', + DateTime(2023, 3, 1), ['user', 'editor'])); + }); + + test('should find all accounts', () async { + // Act + final accounts = await repository.findAll(); + + // Assert + expect(accounts.length, equals(3)); + }); + + test('should filter by email pattern', () async { + // Act + final accounts = await repository.findAll( + QueryParams(filters: {'email': '%example%'}), + ); + + // Assert + expect(accounts.length, equals(2)); + expect(accounts.every((a) => a.email.contains('example')), isTrue); + }); + + test('should filter by role', () async { + // Act + final userAccounts = await repository.findAll( + QueryParams(filters: {'roles': 'user'}), + ); + + // Assert + expect(userAccounts.length, equals(2)); + expect(userAccounts.every((a) => a.roles.contains('user')), isTrue); + }); + + test('should support pagination', () async { + // Act + final page = await repository.findPage( + QueryParams(limit: 2, offset: 0), + ); + + // Assert + expect(page.items.length, equals(2)); + expect(page.totalCount, equals(3)); + expect(page.hasNext, isTrue); + expect(page.hasPrevious, isFalse); + }); + + test('should sort results', () async { + // Act + final accounts = await repository.findAll( + QueryParams(orderBy: ['email'], ascending: true), + ); + + // Assert + expect(accounts.length, equals(3)); + expect(accounts[0].email, equals('alice@example.com')); + expect(accounts[1].email, equals('bob@company.com')); + expect(accounts[2].email, equals('charlie@example.com')); + }); + }); + + group('Account-Specific Repository Operations', () { + test('should create account with defaults', () async { + // Act + final account = await repository.createAccountWithDefaults( + 'defaults@example.com', + 'hashedPass123', + roles: ['admin', 'user'], + ); + + // Assert + expect(account.email, equals('defaults@example.com')); + expect(account.password, equals('hashedPass123')); + expect(account.roles, equals(['admin', 'user'])); + expect(account.id, isPositive); + expect(account.creationDate, isA()); + }); + + test('should find account by email', () async { + // Arrange + await repository.createAccountWithDefaults('findme@example.com', 'pass'); + + // Act + final account = await repository.findByEmail('findme@example.com'); + + // Assert + expect(account, isNotNull); + expect(account!.email, equals('findme@example.com')); + }); + + test('should check email availability', () async { + // Arrange + await repository.createAccountWithDefaults('taken@example.com', 'pass'); + + // Act & Assert + expect(await repository.isEmailTaken('taken@example.com'), isTrue); + expect(await repository.isEmailTaken('available@example.com'), isFalse); + }); + + test('should update password directly', () async { + // Arrange + final account = await repository.createAccountWithDefaults( + 'password@example.com', 'oldPass'); + + // Act + final updated = await repository.updatePassword(account.id, 'newPass'); + + // Assert + expect(updated.password, equals('newPass')); + expect(updated.email, equals(account.email)); // Other fields unchanged + expect(updated.id, equals(account.id)); + }); + + test('should manage roles directly', () async { + // Arrange + final account = await repository.createAccountWithDefaults( + 'roles@example.com', 'pass', + roles: ['user']); + + // Act - Add role + final withAdmin = await repository.addRole(account.id, 'admin'); + expect(withAdmin.roles, containsAll(['user', 'admin'])); + + // Act - Remove role + final withoutUser = await repository.removeRole(account.id, 'user'); + expect(withoutUser.roles, contains('admin')); + expect(withoutUser.roles, isNot(contains('user'))); + }); + }); + + group('Batch Operations', () { + test('should create multiple accounts', () async { + // Arrange + final accounts = [ + Account(0, 'batch1@example.com', 'pass1', DateTime.now(), ['user']), + Account(0, 'batch2@example.com', 'pass2', DateTime.now(), ['admin']), + ]; + + // Act + final created = await repository.createAll(accounts); + + // Assert + expect(created.length, equals(2)); + expect(created.every((a) => a.id > 0), isTrue); + expect(created[0].email, equals('batch1@example.com')); + expect(created[1].email, equals('batch2@example.com')); + }); + + test('should delete multiple accounts by ID', () async { + // Arrange + final account1 = await repository.createAccountWithDefaults( + 'del1@example.com', 'pass'); + final account2 = await repository.createAccountWithDefaults( + 'del2@example.com', 'pass'); + final account3 = await repository.createAccountWithDefaults( + 'keep@example.com', 'pass'); + + // Act + final deletedCount = + await repository.deleteAllById([account1.id, account2.id]); + + // Assert + expect(deletedCount, equals(2)); + expect(await repository.findById(account1.id), isNull); + expect(await repository.findById(account2.id), isNull); + expect(await repository.findById(account3.id), isNotNull); + }); + }); + + group('Advanced Query Operations', () { + test('should search with complex filters', () async { + // Arrange + await repository.createAccountWithDefaults('admin@company.com', 'pass', + roles: ['admin']); + await repository.createAccountWithDefaults('user@company.com', 'pass', + roles: ['user']); + await repository.createAccountWithDefaults('user@personal.com', 'pass', + roles: ['user']); + + // Act + final results = await repository.searchAccounts( + emailSearch: 'company', + roleFilter: 'user', + limit: 10, + ); + + // Assert + expect(results.items.length, equals(1)); + expect(results.items.first.email, equals('user@company.com')); + }); + + test('should find accounts by role', () async { + // Arrange + await repository.createAccountWithDefaults('admin1@example.com', 'pass', + roles: ['admin']); + await repository.createAccountWithDefaults('admin2@example.com', 'pass', + roles: ['admin', 'user']); + await repository.createAccountWithDefaults('user1@example.com', 'pass', + roles: ['user']); + + // Act + final adminAccounts = await repository.findByRole('admin'); + + // Assert + expect(adminAccounts.length, equals(2)); + expect(adminAccounts.every((a) => a.roles.contains('admin')), isTrue); + }); + + test('should find accounts by date range', () async { + // Arrange + final oldDate = DateTime(2023, 1, 1); + final recentDate = DateTime(2023, 6, 1); + + await repository + .create(Account(0, 'old@example.com', 'pass', oldDate, ['user'])); + await repository.create( + Account(0, 'recent@example.com', 'pass', recentDate, ['user'])); + + // Act + final recentAccounts = await repository.findByDateRange( + DateTime(2023, 5, 1), + DateTime(2023, 7, 1), + ); + + // Assert + expect(recentAccounts.length, equals(1)); + expect(recentAccounts.first.email, equals('recent@example.com')); + }); + }); + + group('Data Consistency', () { + test('should maintain referential integrity', () async { + // Arrange + final account = await repository.createAccountWithDefaults( + 'integrity@example.com', 'originalPass'); + + // Act - Multiple operations + await repository.updatePassword(account.id, 'newPass'); + await repository.addRole(account.id, 'admin'); + + // Assert - Verify final state is consistent + final finalAccount = await repository.findById(account.id); + expect(finalAccount!.password, equals('newPass')); + expect(finalAccount.roles, contains('admin')); + expect(finalAccount.email, equals('integrity@example.com')); + expect(finalAccount.id, equals(account.id)); + }); + + test('should handle edge cases gracefully', () async { + // Test various edge cases + expect(await repository.findById(0), isNull); + expect(await repository.deleteById(999), isFalse); + expect(await repository.findByEmail('nonexistent@example.com'), isNull); + + final emptyResults = await repository.findByRole('nonexistent'); + expect(emptyResults.length, equals(0)); + }); + }); +} diff --git a/test/services/accounts/routes/change_password_test.dart b/test/services/accounts/routes/change_password_test.dart new file mode 100644 index 0000000..418118a --- /dev/null +++ b/test/services/accounts/routes/change_password_test.dart @@ -0,0 +1,199 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:shelf/shelf.dart'; +import 'package:test/test.dart'; +import 'package:top_shelf/top_shelf.dart'; +import 'package:top_shelf/src/services/accounts/routes/change_password/handler.dart' + as change_password; +import 'package:top_shelf/src/services/accounts/routes/change_password/middleware.dart' + as change_password; + +import '../../../utils.dart'; + +String jwtSecretKeyFactory() => 'test-jwt'; + +void main() { + group('Change Password Route', () { + late AccountService accountService; + late Handler handler; + + setUp(() async { + // Create in-memory repository and service for testing + final repository = AccountMemoryRepository(); + accountService = AccountService( + repository, + pepperFactory: () => 'test-pepper', + jwtSecretFactory: jwtSecretKeyFactory, + ); + + // Create handler with middleware + handler = Pipeline() + .addMiddleware( + provide((_) => accountService)) + .addMiddleware( + change_password.middleware(secretKeyFactory: jwtSecretKeyFactory)) + .addHandler(change_password.handler); + }); + + test('should change password with valid JWT and correct current password', + () async { + // Arrange + const email = 'test@example.com'; + const currentPassword = 'CurrentPassword123'; + const newPassword = 'NewPassword456'; + + // Create test account + final account = await accountService.createAccount( + email: email, + password: currentPassword, + ); + + final tokens = await accountService.login(account); + + final requestBody = { + 'currentPassword': currentPassword, + 'newPassword': newPassword, + }; + + // Act + final response = await makeRequest( + handler, + method: 'PUT', + body: json.encode(requestBody), + headers: { + 'content-type': 'application/json', + 'authorization': 'Bearer ${tokens.accessToken}', + }, + ); + + // Assert + expect(response.statusCode, HttpStatus.ok); + + final responseBody = await response.readAsString(); + final responseJson = json.decode(responseBody); + expect(responseJson['email'], email); + expect(responseJson['id'], account.id); + + // Verify password was actually changed by trying to authenticate with new password + final authenticatedAccount = + await accountService.authenticate(email, newPassword); + expect(authenticatedAccount, isNotNull); + expect(authenticatedAccount!.id, account.id); + + // Verify old password no longer works + final oldPasswordAuth = + await accountService.authenticate(email, currentPassword); + expect(oldPasswordAuth, isNull); + }); + + test('should return 401 when no authorization header provided', () async { + // Arrange + final requestBody = { + 'currentPassword': 'current', + 'newPassword': 'new', + }; + + // Act + final response = await makeRequest( + handler, + method: 'PUT', + body: json.encode(requestBody), + headers: {'content-type': 'application/json'}, + ); + + // Assert + expect(response.statusCode, HttpStatus.unauthorized); + }); + + test('should return 401 when invalid JWT token provided', () async { + // Arrange + final requestBody = { + 'currentPassword': 'current', + 'newPassword': 'new', + }; + + // Act + final response = await makeRequest( + handler, + method: 'PUT', + body: json.encode(requestBody), + headers: { + 'content-type': 'application/json', + 'authorization': 'Bearer invalid-token', + }, + ); + + // Assert + expect(response.statusCode, HttpStatus.unauthorized); + }); + + test('should return 400 when current password is incorrect', () async { + // Arrange + const email = 'test@example.com'; + const currentPassword = 'CurrentPassword123'; + const wrongPassword = 'WrongPassword'; + const newPassword = 'NewPassword456'; + + // Create test account + final account = await accountService.createAccount( + email: email, + password: currentPassword, + ); + + final tokens = await accountService.login(account); + + final requestBody = { + 'currentPassword': wrongPassword, + 'newPassword': newPassword, + }; + + // Act + final response = await makeRequest( + handler, + method: 'PUT', + body: json.encode(requestBody), + headers: { + 'content-type': 'application/json', + 'authorization': 'Bearer ${tokens.accessToken}', + }, + ); + + // Assert + expect(response.statusCode, HttpStatus.badRequest); + }); + + test('should return 400 when required fields are missing', () async { + // Arrange + const email = 'test@example.com'; + const currentPassword = 'CurrentPassword123'; + + // Create test account + final account = await accountService.createAccount( + email: email, + password: currentPassword, + ); + + final tokens = await accountService.login(account); + + final requestBody = { + 'currentPassword': currentPassword, + // Missing newPassword + }; + + // Act + final response = await makeRequest( + handler, + method: 'PUT', + body: json.encode(requestBody), + headers: { + 'content-type': 'application/json', + 'authorization': 'Bearer ${tokens.accessToken}', + }, + ); + + // Assert + expect(response.statusCode, HttpStatus.badRequest); + }); + }); +} diff --git a/test/services/accounts/service/account_memory_service_test.dart b/test/services/accounts/service/account_memory_service_test.dart new file mode 100644 index 0000000..cb1ff7c --- /dev/null +++ b/test/services/accounts/service/account_memory_service_test.dart @@ -0,0 +1,15 @@ +import 'package:test/test.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_memory_repository.dart'; + +import 'shared_account_service_tests.dart'; + +void main() { + group('Memory Account Service Tests', () { + // Run all shared integration tests + runAccountIntegrationTests( + () async { + return AccountMemoryRepository(); + }, + ); + }); +} diff --git a/test/services/accounts/service/account_sqlite_service_test.dart b/test/services/accounts/service/account_sqlite_service_test.dart new file mode 100644 index 0000000..f9b0e2b --- /dev/null +++ b/test/services/accounts/service/account_sqlite_service_test.dart @@ -0,0 +1,31 @@ +import 'package:test/test.dart'; +import 'package:sqlite3/sqlite3.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_sqlite_repository.dart'; + +import 'shared_account_service_tests.dart'; + +void main() { + group('SQLite Account Repository Integration Tests', () { + late Database database; + + // Run all shared integration tests + runAccountIntegrationTests( + () async { + database = sqlite3.openInMemory(); + database.execute(''' + CREATE TABLE accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + email TEXT UNIQUE NOT NULL, + password TEXT NOT NULL, + creationDate TEXT NOT NULL, + roles TEXT NOT NULL DEFAULT 'user' + ) + '''); + return AccountSqliteRepository(database); + }, + () async { + database.dispose(); + }, + ); + }); +} diff --git a/test/services/accounts/service/shared_account_service_tests.dart b/test/services/accounts/service/shared_account_service_tests.dart new file mode 100644 index 0000000..bf48021 --- /dev/null +++ b/test/services/accounts/service/shared_account_service_tests.dart @@ -0,0 +1,678 @@ +import 'package:test/test.dart'; +import 'package:top_shelf/src/services/accounts/models/account.dart'; +import 'package:top_shelf/src/services/common/services/account/account_service.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_repository_interface.dart'; +import 'package:top_shelf/src/services/common/repositories/account/account_memory_repository.dart'; +import 'package:top_shelf/src/services/common/services/base_crud_service.dart'; + +/// Shared integration test suite for AccountService with repository implementations +/// Focuses on business logic, validation, and end-to-end workflows through AccountService +void runAccountIntegrationTests( + Future Function() repositoryFactory, + [Future Function()? cleanup]) { + late AccountRepositoryInterface repository; + late AccountService accountService; + + // Test pepper factory + String testPepperFactory() => 'test_pepper_for_integration_tests'; + + // Test JWT secret factory + String testJwtSecretFactory() => 'test_jwt_secret_key_for_testing'; + + setUp(() async { + repository = await repositoryFactory(); + accountService = AccountService( + repository, + pepperFactory: testPepperFactory, + jwtSecretFactory: testJwtSecretFactory, + ); + }); + + tearDown(() async { + // Clear repository data if it supports it + if (repository is AccountMemoryRepository) { + (repository as AccountMemoryRepository).clear(); + } + + // Run custom cleanup if provided + if (cleanup != null) { + await cleanup(); + } + }); + + group('Account Creation with Business Logic', () { + test('should create account with password hashing', () async { + // Arrange + const email = 'business@example.com'; + const plainPassword = 'PlainPassword123'; + + // Act + final account = await accountService.createAccount( + email: email, + password: plainPassword, + ); + + // Assert - Password should be hashed, not plain text + expect(account.id, isPositive); + expect(account.email, equals(email)); + expect( + account.password, isNot(equals(plainPassword))); // Should be hashed + expect(account.password, isNotEmpty); + expect(account.roles, equals(['user'])); // Default role + }); + + test('should enforce business rule for unique emails', () async { + // Arrange + const email = 'duplicate@example.com'; + const password = 'TestPassword123'; + + await accountService.createAccount(email: email, password: password); + + // Act & Assert - Service should prevent duplicate emails + expect( + () => accountService.createAccount(email: email, password: password), + throwsA(isA()), + ); + }); + + test('should create account with custom roles', () async { + // Arrange + const email = 'admin@example.com'; + const password = 'TestPassword123'; + final roles = ['admin', 'user']; + + // Act + final account = await accountService.createAccount( + email: email, + password: password, + roles: roles, + ); + + // Assert + expect(account.roles, equals(roles)); + }); + + test('should validate account creation through service', () async { + // This tests the service's validation logic, not just repository storage + const email = 'validate@example.com'; + const password = 'ValidPassword123'; + + // Act + final account = await accountService.createAccount( + email: email, + password: password, + ); + + // Assert - Service ensures proper validation occurred + expect(account.email, equals(email)); + expect(account.creationDate, isA()); + expect(account.roles, isNotEmpty); + }); + }); + + group('Role Management Business Logic', () { + test('should add role with business validation', () async { + // Arrange + const email = 'roles@example.com'; + const password = 'TestPassword123'; + + final account = + await accountService.createAccount(email: email, password: password); + + // Act - Service handles role validation + final updatedAccount = await accountService.addRole(account.id, 'admin'); + + // Assert + expect(updatedAccount.roles, contains('admin')); + expect(updatedAccount.roles, contains('user')); + }); + + test('should remove role through service', () async { + // Arrange + const email = 'rolesremove@example.com'; + const password = 'TestPassword123'; + final initialRoles = ['user', 'admin']; + + final account = await accountService.createAccount( + email: email, + password: password, + roles: initialRoles, + ); + + // Act + final updatedAccount = + await accountService.removeRole(account.id, 'admin'); + + // Assert + expect(updatedAccount.roles, equals(['user'])); + expect(updatedAccount.roles, isNot(contains('admin'))); + }); + + test('should validate role management', () async { + // Arrange + const email = 'rolevalidation@example.com'; + const password = 'TestPassword123'; + + final account = + await accountService.createAccount(email: email, password: password); + + // Act & Assert - Service should validate roles + expect( + () => accountService.addRole(account.id, 'invalid_role'), + throwsA(isA()), + ); + }); + }); + + group('Search and Reporting Business Logic', () { + test('should search accounts with business logic', () async { + // Arrange - Create accounts through service (with proper hashing, validation) + await accountService.createAccount( + email: 'alice@example.com', password: 'Password123'); + await accountService.createAccount( + email: 'bob@test.com', password: 'Password123'); + await accountService.createAccount( + email: 'charlie@example.com', password: 'Password123'); + + // Act - Search through service layer + final result = + await accountService.searchAccounts(emailSearch: 'example'); + + // Assert + expect(result.items.length, equals(2)); + expect(result.totalCount, equals(2)); + expect(result.items.every((account) => account.email.contains('example')), + isTrue); + }); + + test('should search by role with proper service logic', () async { + // Arrange + await accountService.createAccount( + email: 'user1@example.com', password: 'Password123'); + await accountService.createAccount( + email: 'admin1@example.com', + password: 'Password123', + roles: ['admin']); + await accountService.createAccount( + email: 'admin2@example.com', + password: 'Password123', + roles: ['admin']); + + // Act + final result = await accountService.searchAccounts(roleFilter: 'admin'); + + // Assert + expect(result.items.length, equals(2)); + expect(result.items.every((account) => account.roles.contains('admin')), + isTrue); + }); + + test('should provide accurate statistics through service', () async { + // Arrange + await accountService.createAccount( + email: 'user1@example.com', password: 'Password123'); + await accountService.createAccount( + email: 'admin@example.com', + password: 'Password123', + roles: ['admin']); + await accountService.createAccount( + email: 'mod@example.com', + password: 'Password123', + roles: ['moderator']); + + // Act + final stats = await accountService.getAccountStatistics(); + + // Assert - Service provides business-level statistics + expect(stats, isA>()); + expect(stats.isNotEmpty, isTrue); + + // Check for total count (field name may vary between implementations) + expect( + stats.containsKey('total_accounts') || + stats.containsKey('totalAccounts'), + isTrue); + + final totalKey = stats.containsKey('total_accounts') + ? 'total_accounts' + : 'totalAccounts'; + expect(stats[totalKey], equals(3)); + }); + }); + + group('Business Query Operations', () { + test('should find accounts by role through service', () async { + // Arrange + await accountService.createAccount( + email: 'user@example.com', password: 'Password123'); + await accountService.createAccount( + email: 'admin1@example.com', + password: 'Password123', + roles: ['admin']); + await accountService.createAccount( + email: 'admin2@example.com', + password: 'Password123', + roles: ['admin', 'user']); + + // Act - Query through service layer + final adminAccounts = await accountService.findAccountsByRole('admin'); + final userAccounts = await accountService.findAccountsByRole('user'); + + // Assert + expect(adminAccounts.length, equals(2)); + expect(adminAccounts.every((account) => account.roles.contains('admin')), + isTrue); + + expect(userAccounts.length, + equals(2)); // First and third accounts have 'user' role + expect(userAccounts.every((account) => account.roles.contains('user')), + isTrue); + }); + + test('should find accounts by date range through service', () async { + // Arrange + final yesterday = DateTime.now().subtract(const Duration(days: 1)); + final tomorrow = DateTime.now().add(const Duration(days: 1)); + + await accountService.createAccount( + email: 'recent@example.com', password: 'Password123'); + await accountService.createAccount( + email: 'today@example.com', password: 'Password123'); + + // Act + final accounts = + await accountService.findAccountsByDateRange(yesterday, tomorrow); + + // Assert + expect(accounts.length, equals(2)); + expect( + accounts.every((account) => + account.creationDate.isAfter(yesterday) && + account.creationDate.isBefore(tomorrow)), + isTrue); + }); + }); + + group('Password Management Business Logic', () { + test('should change password with validation', () async { + // Arrange + const email = 'password@example.com'; + const currentPassword = 'CurrentPassword123'; + const newPassword = 'NewPassword456'; + + final account = await accountService.createAccount( + email: email, password: currentPassword); + + // Act + final updatedAccount = await accountService.changePassword( + account.id, currentPassword, newPassword); + + // Assert - Password should be hashed and changed + expect(updatedAccount.password, isNot(equals(currentPassword))); + expect(updatedAccount.password, isNot(equals(newPassword))); + expect(updatedAccount.password, isNot(equals(account.password))); + expect(updatedAccount.email, equals(account.email)); + }); + + test('should validate current password when changing', () async { + // Arrange + const email = 'passwordvalidation@example.com'; + const currentPassword = 'CurrentPassword123'; + const wrongPassword = 'WrongPassword123'; + const newPassword = 'NewPassword456'; + + final account = await accountService.createAccount( + email: email, password: currentPassword); + + // Act & Assert - Service should validate current password + expect( + () => accountService.changePassword( + account.id, wrongPassword, newPassword), + throwsA(isA()), + ); + }); + + test('should validate new password strength', () async { + // Arrange + const email = 'weakpassword@example.com'; + const currentPassword = 'CurrentPassword123'; + const weakPassword = 'weak'; + + final account = await accountService.createAccount( + email: email, password: currentPassword); + + // Act & Assert - Service should validate new password + expect( + () => accountService.changePassword( + account.id, currentPassword, weakPassword), + throwsA(isA()), + ); + }); + }); + + group('Authentication Business Logic', () { + test('should authenticate with correct credentials', () async { + // Arrange + const email = 'auth@example.com'; + const password = 'TestPassword123'; + + await accountService.createAccount(email: email, password: password); + + // Act + final authenticatedAccount = + await accountService.authenticate(email, password); + + // Assert + expect(authenticatedAccount, isNotNull); + expect(authenticatedAccount!.email, equals(email)); + }); + + test('should reject authentication with wrong password', () async { + // Arrange + const email = 'authfail@example.com'; + const correctPassword = 'CorrectPassword123'; + const wrongPassword = 'WrongPassword123'; + + await accountService.createAccount( + email: email, password: correctPassword); + + // Act + final authenticatedAccount = + await accountService.authenticate(email, wrongPassword); + + // Assert + expect(authenticatedAccount, isNull); + }); + + test('should reject authentication with non-existent email', () async { + // Act + final authenticatedAccount = await accountService.authenticate( + 'nonexistent@example.com', 'Password123'); + + // Assert + expect(authenticatedAccount, isNull); + }); + }); + + group('Login Token Generation', () { + test('should generate JWT tokens for authenticated account', () async { + // Arrange + const email = 'login@example.com'; + const password = 'TestPassword123'; + + final account = + await accountService.createAccount(email: email, password: password); + + // Act + final tokens = await accountService.login(account); + + // Assert + expect(tokens.accessToken, isNotEmpty); + expect(tokens.refreshToken, isNotEmpty); + expect(tokens.accessToken, isNot(equals(tokens.refreshToken))); + + // Verify tokens are JWT format (contain dots) + expect(tokens.accessToken.contains('.'), isTrue); + expect(tokens.refreshToken.contains('.'), isTrue); + }); + + test('should generate different tokens for different accounts', () async { + // Arrange + final account1 = await accountService.createAccount( + email: 'user1@example.com', password: 'Password123'); + final account2 = await accountService.createAccount( + email: 'user2@example.com', password: 'Password123'); + + // Act + final tokens1 = await accountService.login(account1); + final tokens2 = await accountService.login(account2); + + // Assert + expect(tokens1.accessToken, isNot(equals(tokens2.accessToken))); + expect(tokens1.refreshToken, isNot(equals(tokens2.refreshToken))); + }); + + test('should generate different tokens for same account on multiple logins', + () async { + // Arrange + final account = await accountService.createAccount( + email: 'multiple@example.com', password: 'Password123'); + + // Act + final tokens1 = await accountService.login(account); + + // Wait a moment to ensure different timestamps + await Future.delayed(Duration(seconds: 1)); + + final tokens2 = await accountService.login(account); + + // Assert + expect(tokens1.accessToken, isNot(equals(tokens2.accessToken))); + expect(tokens1.refreshToken, isNot(equals(tokens2.refreshToken))); + }); + }); + + group('Token Refresh Logic', () { + test('should refresh tokens with valid refresh token', () async { + // Arrange + final account = await accountService.createAccount( + email: 'refresh@example.com', password: 'Password123'); + final originalTokens = await accountService.login(account); + + // Act - Add small delay to ensure different timestamps + await Future.delayed(Duration(seconds: 1)); + final newTokens = + await accountService.refreshTokens(originalTokens.refreshToken); + + // Assert + expect(newTokens.accessToken, isNotEmpty); + expect(newTokens.refreshToken, isNotEmpty); + expect(newTokens.accessToken, isNot(equals(originalTokens.accessToken))); + expect( + newTokens.refreshToken, isNot(equals(originalTokens.refreshToken))); + + // Verify tokens are JWT format + expect(newTokens.accessToken.contains('.'), isTrue); + expect(newTokens.refreshToken.contains('.'), isTrue); + }); + + test('should reject invalid refresh token', () async { + // Arrange + const invalidToken = 'invalid.token.format'; + + // Act & Assert + expect( + () => accountService.refreshTokens(invalidToken), + throwsA(isA()), + ); + }); + + test('should reject refresh token for non-existent account', () async { + // Arrange - Create and then delete an account + final account = await accountService.createAccount( + email: 'toDelete@example.com', password: 'Password123'); + final tokens = await accountService.login(account); + + // Delete the account to simulate non-existent user + await accountService.deleteById(account.id); + + // Act & Assert + expect( + () => accountService.refreshTokens(tokens.refreshToken), + throwsA(isA()), + ); + }); + + test('should reject malformed refresh token', () async { + // Arrange + const malformedToken = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.malformed.signature'; + + // Act & Assert + expect( + () => accountService.refreshTokens(malformedToken), + throwsA(isA()), + ); + }); + + test('should generate different tokens on each refresh', () async { + // Arrange + final account = await accountService.createAccount( + email: 'multiRefresh@example.com', password: 'Password123'); + final originalTokens = await accountService.login(account); + + // Act + final tokens1 = + await accountService.refreshTokens(originalTokens.refreshToken); + + // Wait to ensure different timestamps + await Future.delayed(Duration(seconds: 1)); + + final tokens2 = await accountService.refreshTokens(tokens1.refreshToken); + + // Assert + expect(tokens1.accessToken, isNot(equals(tokens2.accessToken))); + expect(tokens1.refreshToken, isNot(equals(tokens2.refreshToken))); + }); + }); + + group('Concurrent Business Operations', () { + test('should handle concurrent account creation properly', () async { + // Arrange + final futures = >[]; + + // Act - Create multiple accounts concurrently through service + for (int i = 0; i < 5; i++) { + futures.add( + accountService.createAccount( + email: 'concurrent$i@example.com', + password: 'Password123', + ), + ); + } + + final accounts = await Future.wait(futures); + + // Assert + expect(accounts.length, equals(5)); + + // Verify all accounts have unique IDs and proper hashing + final ids = accounts.map((a) => a.id).toSet(); + expect(ids.length, equals(5)); + + // Verify all passwords are hashed (different from input) + expect(accounts.every((a) => a.password != 'Password123'), isTrue); + expect(accounts.every((a) => a.password.isNotEmpty), isTrue); + }); + + test('should handle concurrent role updates', () async { + // Arrange + const email = 'concurrent-roles@example.com'; + const password = 'TestPassword123'; + + final account = + await accountService.createAccount(email: email, password: password); + + // Act - Add multiple roles concurrently through service + final futures = [ + accountService.addRole(account.id, 'admin'), + accountService.addRole(account.id, 'moderator'), + accountService.addRole(account.id, 'guest'), + ]; + + await Future.wait(futures); + + // Assert - Service should handle concurrent updates properly + final stats = await accountService.getAccountStatistics(); + expect(stats, isA>()); + }); + }); + + group('Service Validation Rules', () { + test('should validate email format', () async { + // Act & Assert + expect( + () => accountService.createAccount( + email: 'invalid-email', password: 'ValidPassword123'), + throwsA(isA()), + ); + }); + + test('should enforce password requirements', () async { + // Act & Assert - Test various invalid passwords + expect( + () => accountService.createAccount( + email: 'test@example.com', password: ''), + throwsA(isA()), + ); + + expect( + () => accountService.createAccount( + email: 'test@example.com', password: 'short'), + throwsA(isA()), + ); + + expect( + () => accountService.createAccount( + email: 'test@example.com', password: 'nouppercase123'), + throwsA(isA()), + ); + }); + + test('should validate role assignments', () async { + // Arrange + const email = 'rolevalidation@example.com'; + const password = 'ValidPassword123'; + + final account = + await accountService.createAccount(email: email, password: password); + + // Act & Assert + expect( + () => accountService.addRole(account.id, 'invalid_role'), + throwsA(isA()), + ); + }); + }); + + group('Error Handling and Edge Cases', () { + test('should handle non-existent account operations gracefully', () async { + // Act & Assert + expect( + () => accountService.addRole(999, 'admin'), + throwsA(isA()), + ); + + expect( + () => accountService.changePassword(999, 'old', 'new'), + throwsA(isA()), + ); + }); + + test('should handle empty search results', () async { + // Act + final result = + await accountService.searchAccounts(emailSearch: 'nonexistent'); + + // Assert + expect(result.items.length, equals(0)); + expect(result.totalCount, equals(0)); + }); + + test('should handle pagination edge cases', () async { + // Arrange + for (int i = 1; i <= 3; i++) { + await accountService.createAccount( + email: 'user$i@example.com', password: 'Password123'); + } + + // Act - Request page beyond available data + final result = + await accountService.searchAccounts(limit: 10, offset: 100); + + // Assert + expect(result.items.length, equals(0)); + expect(result.totalCount, equals(3)); + }); + }); +}