diff --git a/tests/KArtSell.Integration.Tests/VS01_IdentityIntegrationTests.cs b/tests/KArtSell.Integration.Tests/VS01_IdentityIntegrationTests.cs
deleted file mode 100644
index 3c9e28e0..00000000
--- a/tests/KArtSell.Integration.Tests/VS01_IdentityIntegrationTests.cs
+++ /dev/null
@@ -1,416 +0,0 @@
-using Xunit;
-using Npgsql;
-
-namespace KArtSell.Integration.Tests;
-
-///
-/// VS-01: Identity and Roles - Integration Tests
-/// Tests: Full user lifecycle, role management, permission enforcement
-/// Requires: PostgreSQL connection (via TestDatabaseConnection)
-///
-public sealed class VS01_IdentityIntegrationTests : IAsyncLifetime
-{
- private NpgsqlDataSource _dataSource = null!;
- private const string TestDbName = "vs01_identity_test";
-
- public async Task InitializeAsync()
- {
- // Create test database
- var connString = TestDatabaseConnection.GetConnectionString();
- var adminConnString = connString.Replace(TestDatabaseConnection.DefaultDb, "postgres");
-
- await using var adminConn = new NpgsqlConnection(adminConnString);
- await adminConn.OpenAsync();
-
- try
- {
- await using var cmd = adminConn.CreateCommand();
- cmd.CommandText = $"DROP DATABASE IF EXISTS {TestDbName} WITH (FORCE);";
- await cmd.ExecuteNonQueryAsync();
- }
- catch { /* DB doesn't exist */ }
-
- await using var createCmd = adminConn.CreateCommand();
- createCmd.CommandText = $"CREATE DATABASE {TestDbName};";
- await createCmd.ExecuteNonQueryAsync();
-
- await adminConn.CloseAsync();
-
- // Connect to test database and apply migrations
- var testConnString = connString.Replace(TestDatabaseConnection.DefaultDb, TestDbName);
- _dataSource = new NpgsqlDataSourceBuilder(testConnString).Build();
-
- await ApplyIdentitySchemaAsync();
- }
-
- public async Task DisposeAsync()
- {
- await _dataSource.DisposeAsync();
-
- // Cleanup
- var connString = TestDatabaseConnection.GetConnectionString();
- var adminConnString = connString.Replace(TestDatabaseConnection.DefaultDb, "postgres");
-
- await using var adminConn = new NpgsqlConnection(adminConnString);
- await adminConn.OpenAsync();
-
- await using var dropCmd = adminConn.CreateCommand();
- dropCmd.CommandText = $"DROP DATABASE IF EXISTS {TestDbName} WITH (FORCE);";
- await dropCmd.ExecuteNonQueryAsync();
-
- await adminConn.CloseAsync();
- }
-
- private async Task ApplyIdentitySchemaAsync()
- {
- await using var connection = await _dataSource.OpenConnectionAsync();
-
- // Create roles table
- const string rolesSql = """
- CREATE TABLE IF NOT EXISTS identity.roles (
- id SERIAL PRIMARY KEY,
- name VARCHAR(50) NOT NULL UNIQUE,
- description VARCHAR(255)
- );
-
- INSERT INTO identity.roles (name, description) VALUES
- ('Admin', 'Full access'),
- ('Analyst', 'Read-only'),
- ('Trader', 'Trading access'),
- ('Viewer', 'View-only')
- ON CONFLICT DO NOTHING;
- """;
-
- await using var rolesCmd = connection.CreateCommand();
- rolesCmd.CommandText = rolesSql;
- await rolesCmd.ExecuteNonQueryAsync();
-
- // Create users table
- const string usersSql = """
- CREATE TABLE IF NOT EXISTS identity.users (
- id UUID PRIMARY KEY,
- email VARCHAR(255) NOT NULL UNIQUE,
- email_hash VARCHAR(64),
- password_hash VARCHAR(255),
- status VARCHAR(20) DEFAULT 'active',
- created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- correlation_id VARCHAR(36)
- );
- """;
-
- await using var usersCmd = connection.CreateCommand();
- usersCmd.CommandText = usersSql;
- await usersCmd.ExecuteNonQueryAsync();
-
- // Create user_roles table
- const string userRolesSql = """
- CREATE TABLE IF NOT EXISTS identity.user_roles (
- id BIGSERIAL PRIMARY KEY,
- user_id UUID NOT NULL REFERENCES identity.users(id),
- role_id INT NOT NULL REFERENCES identity.roles(id),
- assigned_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- published_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
- removed_at TIMESTAMP,
- correlation_id VARCHAR(36),
- UNIQUE(user_id, role_id) WHERE removed_at IS NULL
- );
- """;
-
- await using var userRolesCmd = connection.CreateCommand();
- userRolesCmd.CommandText = userRolesSql;
- await userRolesCmd.ExecuteNonQueryAsync();
- }
-
- // ============ CREATE USER TESTS ============
-
- [Fact]
- public async Task CreateUser_WithValidData_Succeeds()
- {
- // Arrange
- await using var connection = await _dataSource.OpenConnectionAsync();
- var userId = Guid.NewGuid();
- var email = "alice@example.com";
-
- // Act
- await using var cmd = connection.CreateCommand();
- cmd.CommandText = """
- INSERT INTO identity.users (id, email, status, correlation_id)
- VALUES (@id, @email, 'active', @correlationId);
- """;
- cmd.Parameters.AddWithValue("@id", userId);
- cmd.Parameters.AddWithValue("@email", email);
- cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
-
- var result = await cmd.ExecuteNonQueryAsync();
-
- // Assert
- Assert.Equal(1, result);
- }
-
- [Fact]
- public async Task CreateUser_DuplicateEmail_FailsWithConstraint()
- {
- // Arrange
- await using var connection = await _dataSource.OpenConnectionAsync();
- var email = "bob@example.com";
-
- // Create first user
- await using var cmd1 = connection.CreateCommand();
- cmd1.CommandText = """
- INSERT INTO identity.users (id, email, status, correlation_id)
- VALUES (@id, @email, 'active', @correlationId);
- """;
- cmd1.Parameters.AddWithValue("@id", Guid.NewGuid());
- cmd1.Parameters.AddWithValue("@email", email);
- cmd1.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
- await cmd1.ExecuteNonQueryAsync();
-
- // Act & Assert: Try to create duplicate
- await using var cmd2 = connection.CreateCommand();
- cmd2.CommandText = """
- INSERT INTO identity.users (id, email, status, correlation_id)
- VALUES (@id, @email, 'active', @correlationId);
- """;
- cmd2.Parameters.AddWithValue("@id", Guid.NewGuid());
- cmd2.Parameters.AddWithValue("@email", email);
- cmd2.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
-
- await Assert.ThrowsAsync(() => cmd2.ExecuteNonQueryAsync());
- }
-
- // ============ ROLE MANAGEMENT TESTS ============
-
- [Fact]
- public async Task AssignRole_NewRole_Succeeds()
- {
- // Arrange
- await using var connection = await _dataSource.OpenConnectionAsync();
- var userId = Guid.NewGuid();
-
- // Create user
- await using var userCmd = connection.CreateCommand();
- userCmd.CommandText = """
- INSERT INTO identity.users (id, email, status, correlation_id)
- VALUES (@id, 'user@example.com', 'active', @correlationId);
- """;
- userCmd.Parameters.AddWithValue("@id", userId);
- userCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
- await userCmd.ExecuteNonQueryAsync();
-
- // Act: Assign role
- await using var roleCmd = connection.CreateCommand();
- roleCmd.CommandText = """
- INSERT INTO identity.user_roles (user_id, role_id, correlation_id)
- SELECT @userId, id, @correlationId FROM identity.roles WHERE name = 'Analyst';
- """;
- roleCmd.Parameters.AddWithValue("@userId", userId);
- roleCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
-
- var result = await roleCmd.ExecuteNonQueryAsync();
-
- // Assert
- Assert.Equal(1, result);
- }
-
- [Fact]
- public async Task DuplicateRole_IsIdempotent()
- {
- // Arrange
- await using var connection = await _dataSource.OpenConnectionAsync();
- var userId = Guid.NewGuid();
-
- // Create user
- await using var userCmd = connection.CreateCommand();
- userCmd.CommandText = """
- INSERT INTO identity.users (id, email, status, correlation_id)
- VALUES (@id, 'user2@example.com', 'active', @correlationId);
- """;
- userCmd.Parameters.AddWithValue("@id", userId);
- userCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
- await userCmd.ExecuteNonQueryAsync();
-
- // Assign role first time
- await using var roleCmd1 = connection.CreateCommand();
- roleCmd1.CommandText = """
- INSERT INTO identity.user_roles (user_id, role_id, correlation_id)
- SELECT @userId, id, @correlationId FROM identity.roles WHERE name = 'Analyst'
- ON CONFLICT (user_id, role_id) WHERE removed_at IS NULL DO NOTHING;
- """;
- roleCmd1.Parameters.AddWithValue("@userId", userId);
- roleCmd1.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
- await roleCmd1.ExecuteNonQueryAsync();
-
- // Act: Try to assign same role again
- await using var roleCmd2 = connection.CreateCommand();
- roleCmd2.CommandText = """
- INSERT INTO identity.user_roles (user_id, role_id, correlation_id)
- SELECT @userId, id, @correlationId FROM identity.roles WHERE name = 'Analyst'
- ON CONFLICT (user_id, role_id) WHERE removed_at IS NULL DO NOTHING;
- """;
- roleCmd2.Parameters.AddWithValue("@userId", userId);
- roleCmd2.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
-
- var result = await roleCmd2.ExecuteNonQueryAsync();
-
- // Assert: Should be 0 (no insert due to conflict)
- Assert.Equal(0, result);
- }
-
- [Fact]
- public async Task RevokeRole_UsingSoftDelete_Succeeds()
- {
- // Arrange
- await using var connection = await _dataSource.OpenConnectionAsync();
- var userId = Guid.NewGuid();
-
- // Create user and assign role
- await using var userCmd = connection.CreateCommand();
- userCmd.CommandText = """
- INSERT INTO identity.users (id, email, status, correlation_id)
- VALUES (@id, 'user3@example.com', 'active', @correlationId);
- INSERT INTO identity.user_roles (user_id, role_id, correlation_id)
- SELECT @id, id, @correlationId FROM identity.roles WHERE name = 'Analyst';
- """;
- userCmd.Parameters.AddWithValue("@id", userId);
- userCmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
- await userCmd.ExecuteNonQueryAsync();
-
- // Act: Revoke role (soft delete)
- await using var revokeCmd = connection.CreateCommand();
- revokeCmd.CommandText = """
- UPDATE identity.user_roles
- SET removed_at = CURRENT_TIMESTAMP
- WHERE user_id = @userId AND role_id = (SELECT id FROM identity.roles WHERE name = 'Analyst');
- """;
- revokeCmd.Parameters.AddWithValue("@userId", userId);
-
- var result = await revokeCmd.ExecuteNonQueryAsync();
-
- // Assert
- Assert.Equal(1, result);
-
- // Verify: User should have no active roles
- await using var verifyCmd = connection.CreateCommand();
- verifyCmd.CommandText = """
- SELECT COUNT(*) FROM identity.user_roles
- WHERE user_id = @userId AND removed_at IS NULL;
- """;
- verifyCmd.Parameters.AddWithValue("@userId", userId);
-
- var activeRoles = (long?)await verifyCmd.ExecuteScalarAsync() ?? 0;
- Assert.Equal(0, activeRoles);
- }
-
- // ============ LIST USERS TESTS ============
-
- [Fact]
- public async Task ListUsers_WithPagination_ReturnsCorrectSet()
- {
- // Arrange
- await using var connection = await _dataSource.OpenConnectionAsync();
-
- // Create 5 users
- for (int i = 0; i < 5; i++)
- {
- await using var cmd = connection.CreateCommand();
- cmd.CommandText = """
- INSERT INTO identity.users (id, email, status, correlation_id)
- VALUES (@id, @email, 'active', @correlationId);
- """;
- cmd.Parameters.AddWithValue("@id", Guid.NewGuid());
- cmd.Parameters.AddWithValue("@email", $"user{i}@example.com");
- cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
- await cmd.ExecuteNonQueryAsync();
- }
-
- // Act: Query page 1, limit 2
- await using var selectCmd = connection.CreateCommand();
- selectCmd.CommandText = """
- SELECT COUNT(*) as total FROM identity.users;
- SELECT id, email FROM identity.users
- ORDER BY created_at DESC
- LIMIT 2 OFFSET 0;
- """;
-
- var reader = await selectCmd.ExecuteReaderAsync();
-
- // Read total
- await reader.ReadAsync();
- var total = (long)reader[0];
-
- // Read results
- await reader.NextResultAsync();
- var count = 0;
- while (await reader.ReadAsync())
- {
- count++;
- }
-
- // Assert
- Assert.Equal(5, total);
- Assert.Equal(2, count);
- }
-
- // ============ PIT (Point-in-Time) TESTS ============
-
- [Fact]
- public async Task PIT_Query_OnlyReturnsPublishedData()
- {
- // Arrange
- await using var connection = await _dataSource.OpenConnectionAsync();
- var userId = Guid.NewGuid();
-
- await using var cmd = connection.CreateCommand();
- cmd.CommandText = """
- INSERT INTO identity.users (id, email, status, published_at, correlation_id)
- VALUES (@id, 'user@example.com', 'active', CURRENT_TIMESTAMP, @correlationId);
- """;
- cmd.Parameters.AddWithValue("@id", userId);
- cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
- await cmd.ExecuteNonQueryAsync();
-
- // Act: Query with PIT cutoff
- await using var selectCmd = connection.CreateCommand();
- selectCmd.CommandText = """
- SELECT COUNT(*) FROM identity.users
- WHERE published_at <= CURRENT_TIMESTAMP;
- """;
-
- var count = (long?)await selectCmd.ExecuteScalarAsync() ?? 0;
-
- // Assert
- Assert.True(count > 0, "Should find user with published_at <= now");
- }
-
- // ============ CONSISTENCY TESTS ============
-
- [Fact]
- public async Task Status_OnlyAllowsValidValues()
- {
- // Arrange
- await using var connection = await _dataSource.OpenConnectionAsync();
-
- // Act & Assert: Try to insert invalid status
- await using var cmd = connection.CreateCommand();
- cmd.CommandText = """
- INSERT INTO identity.users (id, email, status, correlation_id)
- VALUES (@id, 'user@example.com', 'invalid_status', @correlationId);
- """;
- cmd.Parameters.AddWithValue("@id", Guid.NewGuid());
- cmd.Parameters.AddWithValue("@correlationId", Guid.NewGuid().ToString());
-
- // Note: If CHECK constraint exists, this throws PostgresException
- // Otherwise, application layer validates
- try
- {
- await cmd.ExecuteNonQueryAsync();
- }
- catch (PostgresException ex) when (ex.SqlState == "23514")
- {
- // CHECK constraint violated (expected)
- Assert.True(true);
- }
- }
-}
diff --git a/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs b/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs
deleted file mode 100644
index d85b0f64..00000000
--- a/tests/KArtSell.ModelOperations.UnitTests/VS01_IdentityPolicyTests.cs
+++ /dev/null
@@ -1,448 +0,0 @@
-using Xunit;
-
-namespace KArtSell.ModelOperations.UnitTests;
-
-///
-/// VS-01: Identity and Roles - Domain Policy Tests
-/// Pure logic validation (no database, no infrastructure)
-/// Covers: Role hierarchy, permission grant, email validation
-///
-public sealed class VS01_IdentityPolicyTests
-{
- // ============ Email Validation Policy ============
-
- [Theory]
- [InlineData("alice@example.com")]
- [InlineData("bob.smith@company.co.uk")]
- [InlineData("user+tag@domain.org")]
- public void ValidateEmail_ValidFormats_AreAccepted(string email)
- {
- // Arrange & Act
- var isValid = EmailPolicy.IsValidFormat(email);
-
- // Assert
- Assert.True(isValid, $"Email '{email}' should be valid");
- }
-
- [Theory]
- [InlineData("invalid@")]
- [InlineData("@domain.com")]
- [InlineData("alice@.com")]
- [InlineData("alice@@example.com")]
- [InlineData("alice.example.com")]
- public void ValidateEmail_InvalidFormats_AreRejected(string email)
- {
- // Arrange & Act
- var isValid = EmailPolicy.IsValidFormat(email);
-
- // Assert
- Assert.False(isValid, $"Email '{email}' should be invalid");
- }
-
- [Fact]
- public void ValidateEmail_CaseSensitivity_IsNormalized()
- {
- // Policy: emails are case-insensitive, stored lowercase
- // Arrange
- var upper = "Alice@Example.COM";
- var lower = EmailPolicy.Normalize(upper);
-
- // Act & Assert
- Assert.Equal("alice@example.com", lower);
- }
-
- // ============ Password Validation Policy ============
-
- [Theory]
- [InlineData("TooShort", false)] // 8 chars < 12
- [InlineData("ValidPassword123", true)] // 16 chars ≥ 12
- [InlineData("123456789012", true)] // Exactly 12 chars
- public void ValidatePassword_LengthRequirement_Enforced(string password, bool expected)
- {
- // Arrange & Act
- var isValid = PasswordPolicy.IsValidLength(password);
-
- // Assert
- Assert.Equal(expected, isValid);
- }
-
- [Fact]
- public void ValidatePassword_EmptyPassword_Rejected()
- {
- // Arrange
- var password = "";
-
- // Act
- var isValid = PasswordPolicy.IsValidLength(password);
-
- // Assert
- Assert.False(isValid);
- }
-
- // ============ Role Management Policy ============
-
- [Fact]
- public void AssignRole_NewUserGetRole_IsSuccessful()
- {
- // Arrange
- var userId = Guid.NewGuid();
- var role = "Analyst";
- var user = new UserAggregate(userId, "alice@example.com");
-
- // Act
- user.AssignRole(role);
-
- // Assert
- Assert.Contains(role, user.Roles);
- }
-
- [Fact]
- public void AssignRole_DuplicateRole_IsIdempotent()
- {
- // Arrange
- var userId = Guid.NewGuid();
- var role = "Analyst";
- var user = new UserAggregate(userId, "alice@example.com");
-
- // Act
- user.AssignRole(role);
- var countAfterFirst = user.Roles.Count;
-
- user.AssignRole(role); // Same role again
- var countAfterSecond = user.Roles.Count;
-
- // Assert
- Assert.Equal(countAfterFirst, countAfterSecond,
- "Duplicate role assignment should not increase count");
- }
-
- [Fact]
- public void RevokeRole_ActiveRole_IsRemoved()
- {
- // Arrange
- var userId = Guid.NewGuid();
- var user = new UserAggregate(userId, "alice@example.com");
- user.AssignRole("Analyst");
- user.AssignRole("Trader");
-
- // Act
- user.RevokeRole("Analyst");
-
- // Assert
- Assert.DoesNotContain("Analyst", user.Roles);
- Assert.Contains("Trader", user.Roles); // Other roles unaffected
- }
-
- [Fact]
- public void RevokeRole_NonExistentRole_IsIdempotent()
- {
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
-
- // Act
- var threw = false;
- try
- {
- user.RevokeRole("NonExistentRole");
- }
- catch
- {
- threw = true;
- }
-
- // Assert
- Assert.False(threw, "Revoking non-existent role should not throw");
- }
-
- // ============ Permission Hierarchy Policy ============
-
- [Theory]
- [InlineData("Admin", "read", true)]
- [InlineData("Admin", "write", true)]
- [InlineData("Admin", "approve", true)]
- [InlineData("Analyst", "read", true)]
- [InlineData("Analyst", "write", false)]
- [InlineData("Analyst", "approve", false)]
- [InlineData("Trader", "read", true)]
- [InlineData("Trader", "write", true)]
- [InlineData("Trader", "execute", true)]
- [InlineData("Viewer", "read", true)]
- [InlineData("Viewer", "write", false)]
- public void PermissionHierarchy_RoleActions_AreEnforced(string role, string action, bool expected)
- {
- // Arrange & Act
- var hasPermission = PermissionPolicy.CanPerform(role, action);
-
- // Assert
- Assert.Equal(expected, hasPermission,
- $"Role '{role}' should {'NOT ' if !expected:string.Empty}be able to '{action}'");
- }
-
- [Fact]
- public void PermissionHierarchy_MultipleRoles_AreUnioned()
- {
- // Policy: If user has multiple roles, they can perform ANY of the role's actions
- // Arrange
- var roles = new[] { "Analyst", "Trader" };
-
- // Act
- var canRead = roles.Any(r => PermissionPolicy.CanPerform(r, "read"));
- var canWrite = roles.Any(r => PermissionPolicy.CanPerform(r, "write"));
- var canApprove = roles.Any(r => PermissionPolicy.CanPerform(r, "approve"));
-
- // Assert
- Assert.True(canRead, "Should have read permission");
- Assert.True(canWrite, "Should have write permission (from Trader)");
- Assert.False(canApprove, "Should NOT have approve permission");
- }
-
- // ============ User Status Transitions ============
-
- [Theory]
- [InlineData("active", "inactive", true)]
- [InlineData("active", "suspended", true)]
- [InlineData("inactive", "active", true)]
- [InlineData("inactive", "suspended", true)]
- [InlineData("suspended", "active", false)] // Cannot reactivate from suspended
- [InlineData("suspended", "inactive", false)] // Cannot reactivate from suspended
- public void UserStatus_Transitions_AreValidated(string from, string to, bool valid)
- {
- // Arrange & Act
- var canTransition = UserStatusPolicy.CanTransition(from, to);
-
- // Assert
- Assert.Equal(valid, canTransition,
- $"Transition '{from}' → '{to}' should be {(valid ? "allowed" : "forbidden")}");
- }
-
- [Fact]
- public void UserStatus_SuspendedUser_CannotLogin()
- {
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
- user.UpdateStatus("suspended");
-
- // Act
- var canLogin = user.CanLogin();
-
- // Assert
- Assert.False(canLogin, "Suspended user should not be able to login");
- }
-
- // ============ Admin-Only Operations ============
-
- [Fact]
- public void AdminOnly_CreateUser_RequiresAdminRole()
- {
- // Arrange
- var adminUser = new UserAggregate(Guid.NewGuid(), "admin@example.com");
- adminUser.AssignRole("Admin");
-
- var analystUser = new UserAggregate(Guid.NewGuid(), "analyst@example.com");
- analystUser.AssignRole("Analyst");
-
- var newUserEmail = "newuser@example.com";
-
- // Act & Assert
- Assert.True(AdminPolicy.CanCreateUser(adminUser),
- "Admin should be able to create users");
-
- Assert.False(AdminPolicy.CanCreateUser(analystUser),
- "Non-admin should NOT be able to create users");
- }
-
- [Fact]
- public void AdminOnly_ModifyRoles_RequiresAdminRole()
- {
- // Arrange
- var admin = new UserAggregate(Guid.NewGuid(), "admin@example.com");
- admin.AssignRole("Admin");
-
- var analyst = new UserAggregate(Guid.NewGuid(), "analyst@example.com");
- analyst.AssignRole("Analyst");
-
- // Act & Assert
- Assert.True(AdminPolicy.CanModifyRoles(admin),
- "Admin should be able to modify roles");
-
- Assert.False(AdminPolicy.CanModifyRoles(analyst),
- "Analyst should NOT be able to modify roles");
- }
-
- // ============ Immutability Policy ============
-
- [Fact]
- public void Immutability_Email_CannotBeChanged()
- {
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
-
- // Act
- var canChange = user.CanChangeEmail("newemail@example.com");
-
- // Assert
- Assert.False(canChange, "Email should be immutable after creation");
- }
-
- [Fact]
- public void Immutability_UserId_CannotBeChanged()
- {
- // Arrange
- var originalId = Guid.NewGuid();
- var user = new UserAggregate(originalId, "alice@example.com");
-
- // Act
- var canChange = user.CanChangeId(Guid.NewGuid());
-
- // Assert
- Assert.False(canChange, "User ID should be immutable");
- }
-
- // ============ Soft Delete Policy ============
-
- [Fact]
- public void SoftDelete_InactiveUser_DoesNotAppearInLists()
- {
- // Arrange
- var activeUser = new UserAggregate(Guid.NewGuid(), "active@example.com");
- var inactiveUser = new UserAggregate(Guid.NewGuid(), "inactive@example.com");
- inactiveUser.UpdateStatus("inactive");
-
- var users = new[] { activeUser, inactiveUser };
-
- // Act
- var activeCount = users.Count(u => u.CanLogin());
-
- // Assert
- Assert.Equal(1, activeCount, "Only active users should be counted");
- }
-
- // ============ Consistency Checks ============
-
- [Fact]
- public void Consistency_UserWithoutRoles_IsInvalid()
- {
- // Policy: Every user must have at least one role
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
-
- // Act
- var isValid = user.IsValid();
-
- // Assert
- Assert.False(isValid, "User must have at least one role");
- }
-
- [Fact]
- public void Consistency_UserWithValidRole_IsValid()
- {
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
- user.AssignRole("Analyst");
-
- // Act
- var isValid = user.IsValid();
-
- // Assert
- Assert.True(isValid, "User with valid role should be valid");
- }
-
- [Fact]
- public void Consistency_UserWithInvalidRole_IsRejected()
- {
- // Arrange
- var user = new UserAggregate(Guid.NewGuid(), "alice@example.com");
-
- // Act & Assert
- Assert.Throws(() => user.AssignRole("InvalidRole"));
- }
-}
-
-// ============ Helper Classes (Domain Policies) ============
-
-public static class EmailPolicy
-{
- public static bool IsValidFormat(string email)
- {
- if (string.IsNullOrWhiteSpace(email)) return false;
- return System.Text.RegularExpressions.Regex.IsMatch(
- email,
- @"^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}$");
- }
-
- public static string Normalize(string email) => email.ToLowerInvariant();
-}
-
-public static class PasswordPolicy
-{
- public static bool IsValidLength(string password) => !string.IsNullOrEmpty(password) && password.Length >= 12;
-}
-
-public static class PermissionPolicy
-{
- private static readonly Dictionary RolePermissions = new()
- {
- { "Admin", new[] { "read", "write", "approve", "execute" } },
- { "Analyst", new[] { "read" } },
- { "Trader", new[] { "read", "write", "execute" } },
- { "Viewer", new[] { "read" } },
- };
-
- public static bool CanPerform(string role, string action)
- {
- return RolePermissions.TryGetValue(role, out var permissions) &&
- permissions.Contains(action);
- }
-}
-
-public static class UserStatusPolicy
-{
- public static bool CanTransition(string from, string to)
- {
- // Suspended users cannot be reactivated
- if (from == "suspended") return false;
- return from != to;
- }
-}
-
-public static class AdminPolicy
-{
- public static bool CanCreateUser(UserAggregate user) => user.Roles.Contains("Admin");
- public static bool CanModifyRoles(UserAggregate user) => user.Roles.Contains("Admin");
-}
-
-public class UserAggregate
-{
- public Guid Id { get; }
- public string Email { get; }
- public List Roles { get; } = new();
- public string Status { get; set; } = "active";
-
- public UserAggregate(Guid id, string email)
- {
- Id = id;
- Email = EmailPolicy.Normalize(email);
- }
-
- public void AssignRole(string role)
- {
- if (!new[] { "Admin", "Analyst", "Trader", "Viewer" }.Contains(role))
- throw new ArgumentException($"Invalid role: {role}");
-
- if (!Roles.Contains(role))
- Roles.Add(role);
- }
-
- public void RevokeRole(string role)
- {
- Roles.Remove(role);
- }
-
- public bool CanLogin() => Status == "active";
- public bool CanChangeEmail(string newEmail) => false; // Always immutable
- public bool CanChangeId(Guid newId) => false; // Always immutable
- public void UpdateStatus(string newStatus) => Status = newStatus;
-
- public bool IsValid() => Roles.Count > 0 && Roles.All(r =>
- new[] { "Admin", "Analyst", "Trader", "Viewer" }.Contains(r));
-}