diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4365875 --- /dev/null +++ b/.env.example @@ -0,0 +1,3 @@ +POSTGRES_USER=platform +POSTGRES_DB=platform +POSTGRES_PASSWORD=CHANGE_ME_FOR_LOCAL_DEVELOPMENT diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5b252df..e2bb521 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -54,4 +54,31 @@ jobs: run: npm ci --no-audit --no-fund - name: Build frontend working-directory: frontend - run: npm run build \ No newline at end of file + run: npm run build + laboratories: + name: REV-05 domain and persistence + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-dotnet@v4 + with: + dotnet-version: '10.0.x' + - name: Restore EF tool + run: dotnet tool restore + - name: Run domain and model tests + run: dotnet test backend/Platform.Laboratories.Tests/Platform.Laboratories.Tests.csproj --configuration Release --logger "trx;LogFileName=domain.trx" --results-directory TestResults/Domain + - name: Run PostgreSQL integration tests + run: dotnet test backend/Platform.Laboratories.IntegrationTests/Platform.Laboratories.IntegrationTests.csproj --configuration Release --logger "trx;LogFileName=postgres.trx" --results-directory TestResults/Postgres + - name: Check migration snapshot + run: dotnet ef migrations has-pending-model-changes --project backend/Platform.Laboratories.Infrastructure --startup-project backend/Platform.Laboratories.Infrastructure --context LaboratoriesDbContext --configuration Release --no-build + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: rev05-test-results + path: TestResults/**/*.trx + if-no-files-found: ignore + retention-days: 7 diff --git a/backend/Platform.Api/Platform.Api.csproj b/backend/Platform.Api/Platform.Api.csproj index 26028e0..3eb80df 100644 --- a/backend/Platform.Api/Platform.Api.csproj +++ b/backend/Platform.Api/Platform.Api.csproj @@ -1,4 +1,4 @@ - + net10.0 @@ -10,6 +10,8 @@ + + diff --git a/backend/Platform.Api/Program.cs b/backend/Platform.Api/Program.cs index 6859d18..90b0e5e 100644 --- a/backend/Platform.Api/Program.cs +++ b/backend/Platform.Api/Program.cs @@ -1,3 +1,4 @@ +using Platform.Laboratories.Infrastructure.Composition; using Platform.Api.Modules.Laboratories; using Platform.Access.Composition; @@ -66,6 +67,9 @@ builder.Services.AddLaboratories(builder.Environment); +builder.Services.AddLaboratoriesPersistence( + builder.Configuration.GetConnectionString("Laboratories")); + var app = builder.Build(); app.UseIdentityRateLimiting(); diff --git a/backend/Platform.Laboratories.Domain/Contracts/IRevisionDecisionStore.cs b/backend/Platform.Laboratories.Domain/Contracts/IRevisionDecisionStore.cs new file mode 100644 index 0000000..9d5d5ec --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Contracts/IRevisionDecisionStore.cs @@ -0,0 +1,24 @@ +using Platform.Laboratories.Domain.Enums; + +namespace Platform.Laboratories.Domain.Contracts; + +public sealed record RevisionDecisionRequest( + Guid RevisionId, + Guid ReviewerId, + long ExpectedVersion, + string ExpectedContentHash, + RevisionReviewDecision Decision); + +public abstract record DecisionSaveResult +{ + private DecisionSaveResult() { } + public sealed record Success(Guid DecisionId, long RowVersion, LabRevisionStatus Status) : DecisionSaveResult; + public sealed record Failure(RevisionChangeOutcome Reason) : DecisionSaveResult; +} + +public interface IRevisionDecisionStore +{ + Task SaveDecisionAsync( + RevisionDecisionRequest request, + CancellationToken cancellationToken = default); +} diff --git a/backend/Platform.Laboratories.Domain/Enums/LabRevisionStatus.cs b/backend/Platform.Laboratories.Domain/Enums/LabRevisionStatus.cs new file mode 100644 index 0000000..c18aed1 --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Enums/LabRevisionStatus.cs @@ -0,0 +1,9 @@ +namespace Platform.Laboratories.Domain.Enums; + +public enum LabRevisionStatus +{ + Draft = 0, + InReview = 1, + Published = 2, + Archived = 3 +} \ No newline at end of file diff --git a/backend/Platform.Laboratories.Domain/Enums/RevisionApprovalOutcome.cs b/backend/Platform.Laboratories.Domain/Enums/RevisionApprovalOutcome.cs new file mode 100644 index 0000000..2e217e2 --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Enums/RevisionApprovalOutcome.cs @@ -0,0 +1,8 @@ +namespace Platform.Laboratories.Domain.Enums; + +public enum RevisionApprovalOutcome +{ + NotEvaluated = 0, + Allowed = 1, + SelfApprovalForbidden = 2, +} \ No newline at end of file diff --git a/backend/Platform.Laboratories.Domain/Enums/RevisionChangeOutcome.cs b/backend/Platform.Laboratories.Domain/Enums/RevisionChangeOutcome.cs new file mode 100644 index 0000000..3848c1e --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Enums/RevisionChangeOutcome.cs @@ -0,0 +1,17 @@ +namespace Platform.Laboratories.Domain.Enums; + +public enum RevisionChangeOutcome +{ + NotEvaluated = 0, + Applied = 1, + InvalidInput = 2, + ConcurrencyConflict = 3, + ContentChanged = 4, + InvalidTransition = 5, + SelfApprovalForbidden = 6, + PolicyMismatch = 7, + VersionLimitReached = 8, + RevisionNotFound = 9, + PolicyMissing = 10, + StorageFailure = 11 +} diff --git a/backend/Platform.Laboratories.Domain/Enums/RevisionConcurrencyOutcome.cs b/backend/Platform.Laboratories.Domain/Enums/RevisionConcurrencyOutcome.cs new file mode 100644 index 0000000..c9deee9 --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Enums/RevisionConcurrencyOutcome.cs @@ -0,0 +1,9 @@ +namespace Platform.Laboratories.Domain.Enums; + +public enum RevisionConcurrencyOutcome +{ + NotEvaluated = 0, + Allowed = 1, + InvalidVersion = 2, + ConcurrencyConflict = 3 +} diff --git a/backend/Platform.Laboratories.Domain/Enums/RevisionReviewDecision.cs b/backend/Platform.Laboratories.Domain/Enums/RevisionReviewDecision.cs new file mode 100644 index 0000000..5606645 --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Enums/RevisionReviewDecision.cs @@ -0,0 +1,8 @@ +namespace Platform.Laboratories.Domain.Enums; + +public enum RevisionReviewDecision +{ + NotSpecified = 0, + Approved = 1, + ReturnedForChanges = 2 +} diff --git a/backend/Platform.Laboratories.Domain/Models/CoursePublishPolicy.cs b/backend/Platform.Laboratories.Domain/Models/CoursePublishPolicy.cs new file mode 100644 index 0000000..8b52240 --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Models/CoursePublishPolicy.cs @@ -0,0 +1,49 @@ +using Platform.Laboratories.Domain.Enums; + +namespace Platform.Laboratories.Domain.Models; + +public class CoursePublishPolicy +{ + public bool RequiresIndependentApproval { get; } + public Guid CourseId { get; } + + private CoursePublishPolicy(Guid courseId, bool requiresIndependentApproval) + { + CourseId = courseId; + RequiresIndependentApproval = requiresIndependentApproval; + } + + public static CreationResult Create(Guid courseId, bool requiresIndependentApproval) + { + if (courseId == Guid.Empty) + { + return new CreationResult.Failure(CreationError.InvalidCourseId); + } + + return new CreationResult.Success( + new CoursePublishPolicy(courseId, requiresIndependentApproval)); + } + + public RevisionApprovalOutcome EvaluateApproval(bool isAuthor) + { + if (RequiresIndependentApproval && isAuthor) + { + return RevisionApprovalOutcome.SelfApprovalForbidden; + } + + return RevisionApprovalOutcome.Allowed; + } + + public enum CreationError + { + InvalidCourseId = 1 + } + + public abstract record CreationResult + { + private CreationResult() { } + + public sealed record Success(CoursePublishPolicy Value) : CreationResult; + public sealed record Failure(CreationError Error) : CreationResult; + } +} diff --git a/backend/Platform.Laboratories.Domain/Models/LabRevision.cs b/backend/Platform.Laboratories.Domain/Models/LabRevision.cs new file mode 100644 index 0000000..beaa98e --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Models/LabRevision.cs @@ -0,0 +1,115 @@ +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Rules; + +namespace Platform.Laboratories.Domain.Models; + +public sealed class LabRevision +{ + public Guid Id { get; private set; } + public Guid CourseId { get; private set; } + public Guid AuthorId { get; private set; } + public LabRevisionStatus Status { get; private set; } + public string ContentHash { get; private set; } + public long RowVersion { get; private set; } + + private LabRevision(Guid id, Guid courseId, Guid authorId, string contentHash) + { + Id = id; + CourseId = courseId; + AuthorId = authorId; + ContentHash = contentHash; + Status = LabRevisionStatus.Draft; + RowVersion = 1; + } + + public static CreationResult Create(Guid id, Guid courseId, Guid authorId, string contentHash) + { + if (id == Guid.Empty || courseId == Guid.Empty || authorId == Guid.Empty || + !RevisionContentHash.IsValid(contentHash)) + return new CreationResult.Failure(RevisionChangeOutcome.InvalidInput); + + return new CreationResult.Success(new LabRevision(id, courseId, authorId, contentHash)); + } + + public RevisionChangeOutcome SubmitForReview(long expectedVersion) + { + return ChangeStatus(LabRevisionStatus.InReview, expectedVersion); + } + + public RevisionChangeOutcome Archive(long expectedVersion) + { + return ChangeStatus(LabRevisionStatus.Archived, expectedVersion); + } + + public RevisionChangeOutcome Decide( + Guid reviewerId, + long expectedVersion, + string expectedContentHash, + RevisionReviewDecision decision, + CoursePublishPolicy policy) + { + if (reviewerId == Guid.Empty || !RevisionContentHash.IsValid(expectedContentHash) || + decision is not (RevisionReviewDecision.Approved or RevisionReviewDecision.ReturnedForChanges)) + return RevisionChangeOutcome.InvalidInput; + + var versionCheck = CheckVersion(expectedVersion); + if (versionCheck != RevisionChangeOutcome.Applied) + return versionCheck; + + if (!string.Equals(expectedContentHash, ContentHash, StringComparison.Ordinal)) + return RevisionChangeOutcome.ContentChanged; + + if (policy is null) + return RevisionChangeOutcome.PolicyMissing; + + if (policy.CourseId != CourseId) + return RevisionChangeOutcome.PolicyMismatch; + + var target = decision == RevisionReviewDecision.Approved + ? LabRevisionStatus.Published + : LabRevisionStatus.Draft; + if (!LabRevisionTransitions.CanTransition(Status, target) || Status != LabRevisionStatus.InReview) + return RevisionChangeOutcome.InvalidTransition; + + if (decision == RevisionReviewDecision.Approved && + policy.EvaluateApproval(reviewerId == AuthorId) != RevisionApprovalOutcome.Allowed) + return RevisionChangeOutcome.SelfApprovalForbidden; + + Status = target; + RowVersion++; + return RevisionChangeOutcome.Applied; + } + + private RevisionChangeOutcome ChangeStatus(LabRevisionStatus target, long expectedVersion) + { + var versionCheck = CheckVersion(expectedVersion); + if (versionCheck != RevisionChangeOutcome.Applied) + return versionCheck; + + if (!LabRevisionTransitions.CanTransition(Status, target)) + return RevisionChangeOutcome.InvalidTransition; + + Status = target; + RowVersion++; + return RevisionChangeOutcome.Applied; + } + + private RevisionChangeOutcome CheckVersion(long expectedVersion) + { + var result = RevisionConcurrency.Evaluate(expectedVersion, RowVersion); + if (result == RevisionConcurrencyOutcome.InvalidVersion) + return RevisionChangeOutcome.InvalidInput; + if (result != RevisionConcurrencyOutcome.Allowed) + return RevisionChangeOutcome.ConcurrencyConflict; + if (RowVersion == long.MaxValue) + return RevisionChangeOutcome.VersionLimitReached; + return RevisionChangeOutcome.Applied; + } + + public abstract record CreationResult + { + private CreationResult() { } + public sealed record Success(LabRevision Value) : CreationResult; + public sealed record Failure(RevisionChangeOutcome Reason) : CreationResult; + } +} diff --git a/backend/Platform.Laboratories.Domain/Models/RevisionDecisionRecord.cs b/backend/Platform.Laboratories.Domain/Models/RevisionDecisionRecord.cs new file mode 100644 index 0000000..7adfe11 --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Models/RevisionDecisionRecord.cs @@ -0,0 +1,83 @@ +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Rules; + +namespace Platform.Laboratories.Domain.Models; + +public sealed class RevisionDecisionRecord +{ + public Guid Id { get; private set; } + public Guid RevisionId { get; private set; } + public Guid ReviewerId { get; private set; } + public RevisionReviewDecision Decision { get; private set; } + public DateTimeOffset DecidedAt { get; private set; } + public string ContentHash { get; private set; } + public bool RequiresIndependentApproval { get; private set; } + + private RevisionDecisionRecord( + Guid id, + Guid revisionId, + Guid reviewerId, + RevisionReviewDecision decision, + DateTimeOffset decidedAt, + string contentHash, + bool requiresIndependentApproval) + { + Id = id; + RevisionId = revisionId; + ReviewerId = reviewerId; + Decision = decision; + DecidedAt = decidedAt.ToUniversalTime(); + ContentHash = contentHash; + RequiresIndependentApproval = requiresIndependentApproval; + } + + public static CreationResult Create( + Guid id, + Guid revisionId, + Guid reviewerId, + RevisionReviewDecision decision, + DateTimeOffset decidedAt, + string contentHash, + bool requiresIndependentApproval) + { + if (id == Guid.Empty) + return new CreationResult.Failure(CreationError.InvalidId); + + if (revisionId == Guid.Empty) + return new CreationResult.Failure(CreationError.InvalidRevisionId); + + if (reviewerId == Guid.Empty) + return new CreationResult.Failure(CreationError.InvalidReviewerId); + + if (decision is not (RevisionReviewDecision.Approved or RevisionReviewDecision.ReturnedForChanges)) + return new CreationResult.Failure(CreationError.InvalidDecision); + + if (decidedAt == default) + return new CreationResult.Failure(CreationError.InvalidDecisionTime); + + if (!RevisionContentHash.IsValid(contentHash)) + return new CreationResult.Failure(CreationError.InvalidContentHash); + + return new CreationResult.Success(new RevisionDecisionRecord( + id, revisionId, reviewerId, decision, decidedAt, + contentHash, requiresIndependentApproval)); + } + + public enum CreationError + { + InvalidId = 1, + InvalidRevisionId = 2, + InvalidReviewerId = 3, + InvalidDecision = 4, + InvalidDecisionTime = 5, + InvalidContentHash = 6 + } + + public abstract record CreationResult + { + private CreationResult() { } + + public sealed record Success(RevisionDecisionRecord Value) : CreationResult; + public sealed record Failure(CreationError Error) : CreationResult; + } +} diff --git a/backend/Platform.Laboratories.Domain/Platform.Laboratories.Domain.csproj b/backend/Platform.Laboratories.Domain/Platform.Laboratories.Domain.csproj new file mode 100644 index 0000000..b7eb9cb --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Platform.Laboratories.Domain.csproj @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + diff --git a/backend/Platform.Laboratories.Domain/Rules/LabRevisionTransitions.cs b/backend/Platform.Laboratories.Domain/Rules/LabRevisionTransitions.cs new file mode 100644 index 0000000..43af44f --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Rules/LabRevisionTransitions.cs @@ -0,0 +1,20 @@ +using Platform.Laboratories.Domain.Enums; + + +namespace Platform.Laboratories.Domain.Rules +{ + public static class LabRevisionTransitions + { + public static bool CanTransition(LabRevisionStatus from, LabRevisionStatus to) + { + return (from, to) switch + { + (LabRevisionStatus.Draft, LabRevisionStatus.InReview) => true, + (LabRevisionStatus.InReview, LabRevisionStatus.Draft) => true, + (LabRevisionStatus.InReview, LabRevisionStatus.Published) => true, + (LabRevisionStatus.Published, LabRevisionStatus.Archived) => true, + _ => false + }; + } + } +} diff --git a/backend/Platform.Laboratories.Domain/Rules/RevisionConcurrency.cs b/backend/Platform.Laboratories.Domain/Rules/RevisionConcurrency.cs new file mode 100644 index 0000000..e1614db --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Rules/RevisionConcurrency.cs @@ -0,0 +1,17 @@ +using Platform.Laboratories.Domain.Enums; + +namespace Platform.Laboratories.Domain.Rules; + +public static class RevisionConcurrency +{ + public static RevisionConcurrencyOutcome Evaluate(long expectedVersion, long currentVersion) + { + if (expectedVersion < 1 || currentVersion < 1) + return RevisionConcurrencyOutcome.InvalidVersion; + + if (expectedVersion != currentVersion) + return RevisionConcurrencyOutcome.ConcurrencyConflict; + + return RevisionConcurrencyOutcome.Allowed; + } +} diff --git a/backend/Platform.Laboratories.Domain/Rules/RevisionContentHash.cs b/backend/Platform.Laboratories.Domain/Rules/RevisionContentHash.cs new file mode 100644 index 0000000..f94164d --- /dev/null +++ b/backend/Platform.Laboratories.Domain/Rules/RevisionContentHash.cs @@ -0,0 +1,45 @@ +using System.Security.Cryptography; + +namespace Platform.Laboratories.Domain.Rules; + +public static class RevisionContentHash +{ + public const string Algorithm = "SHA-256"; + public const int HexLength = 64; + + public static ComputationResult Compute(ReadOnlySpan snapshot) + { + if (snapshot.IsEmpty) + return new ComputationResult.Failure(ComputationError.EmptySnapshot); + + var hash = Convert.ToHexString(SHA256.HashData(snapshot)).ToLowerInvariant(); + return new ComputationResult.Success(hash); + } + + public static bool IsValid(string hash) + { + if (string.IsNullOrEmpty(hash) || hash.Length != HexLength) + return false; + + foreach (var character in hash) + { + if (character is not (>= '0' and <= '9' or >= 'a' and <= 'f')) + return false; + } + + return true; + } + + public enum ComputationError + { + EmptySnapshot = 1 + } + + public abstract record ComputationResult + { + private ComputationResult() { } + + public sealed record Success(string Value) : ComputationResult; + public sealed record Failure(ComputationError Error) : ComputationResult; + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Composition/LaboratoriesServiceCollectionExtensions.cs b/backend/Platform.Laboratories.Infrastructure/Composition/LaboratoriesServiceCollectionExtensions.cs new file mode 100644 index 0000000..4a60e2a --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Composition/LaboratoriesServiceCollectionExtensions.cs @@ -0,0 +1,24 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Platform.Laboratories.Domain.Contracts; +using Platform.Laboratories.Infrastructure.Persistence; + +namespace Platform.Laboratories.Infrastructure.Composition; + +public static class LaboratoriesServiceCollectionExtensions +{ + public static IServiceCollection AddLaboratoriesPersistence( + this IServiceCollection services, + string? connectionString) + { + services.AddDbContextFactory(options => + options.UseNpgsql(connectionString, postgres => postgres.MigrationsHistoryTable( + "__EFMigrationsHistory", LaboratoriesDbContext.SchemaName))); + + services.TryAddSingleton(TimeProvider.System); + services.AddScoped(); + + return services; + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/Configurations/CoursePublishPolicyConfiguration.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/Configurations/CoursePublishPolicyConfiguration.cs new file mode 100644 index 0000000..2c85b0c --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/Configurations/CoursePublishPolicyConfiguration.cs @@ -0,0 +1,17 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Platform.Laboratories.Domain.Models; + +namespace Platform.Laboratories.Infrastructure.Persistence.Configurations; + +public sealed class CoursePublishPolicyConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("CoursePublishPolicies", table => table.HasCheckConstraint( + "CK_CoursePublishPolicies_CourseId", "\"CourseId\" <> '00000000-0000-0000-0000-000000000000'::uuid")); + builder.HasKey(policy => policy.CourseId); + builder.Property(policy => policy.CourseId).ValueGeneratedNever(); + builder.Property(policy => policy.RequiresIndependentApproval).IsRequired(); + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/Configurations/LabRevisionConfiguration.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/Configurations/LabRevisionConfiguration.cs new file mode 100644 index 0000000..70017f1 --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/Configurations/LabRevisionConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Platform.Laboratories.Domain.Models; +using Platform.Laboratories.Domain.Rules; + +namespace Platform.Laboratories.Infrastructure.Persistence.Configurations; + +public sealed class LabRevisionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("LabRevisions", table => + { + table.HasCheckConstraint("CK_LabRevisions_Id", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.HasCheckConstraint("CK_LabRevisions_CourseId", "\"CourseId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.HasCheckConstraint("CK_LabRevisions_AuthorId", "\"AuthorId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.HasCheckConstraint("CK_LabRevisions_Status", "\"Status\" IN (0, 1, 2, 3)"); + table.HasCheckConstraint("CK_LabRevisions_RowVersion", "\"RowVersion\" > 0"); + table.HasCheckConstraint("CK_LabRevisions_ContentHash", "\"ContentHash\" ~ '^[0-9a-f]{64}$'"); + }); + builder.HasKey(revision => revision.Id); + builder.Property(revision => revision.Id).ValueGeneratedNever(); + builder.Property(revision => revision.CourseId).IsRequired(); + builder.Property(revision => revision.AuthorId).IsRequired(); + builder.Property(revision => revision.Status).HasConversion().IsRequired(); + builder.Property(revision => revision.ContentHash).HasMaxLength(RevisionContentHash.HexLength).IsRequired(); + builder.Property(revision => revision.RowVersion).IsConcurrencyToken().ValueGeneratedNever(); + builder.HasIndex(revision => revision.CourseId); + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/Configurations/RevisionDecisionRecordConfiguration.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/Configurations/RevisionDecisionRecordConfiguration.cs new file mode 100644 index 0000000..665ca65 --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/Configurations/RevisionDecisionRecordConfiguration.cs @@ -0,0 +1,30 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; +using Platform.Laboratories.Domain.Models; +using Platform.Laboratories.Domain.Rules; + +namespace Platform.Laboratories.Infrastructure.Persistence.Configurations; + +public sealed class RevisionDecisionRecordConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.ToTable("RevisionDecisions", table => + { + table.HasCheckConstraint("CK_RevisionDecisions_Id", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.HasCheckConstraint("CK_RevisionDecisions_RevisionId", "\"RevisionId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.HasCheckConstraint("CK_RevisionDecisions_ReviewerId", "\"ReviewerId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.HasCheckConstraint("CK_RevisionDecisions_Decision", "\"Decision\" IN (1, 2)"); + table.HasCheckConstraint("CK_RevisionDecisions_ContentHash", "\"ContentHash\" ~ '^[0-9a-f]{64}$'"); + }); + builder.HasKey(decision => decision.Id); + builder.Property(decision => decision.Id).ValueGeneratedNever(); + builder.Property(decision => decision.RevisionId).IsRequired(); + builder.Property(decision => decision.ReviewerId).IsRequired(); + builder.Property(decision => decision.Decision).HasConversion().IsRequired(); + builder.Property(decision => decision.DecidedAt).HasColumnType("timestamp with time zone").IsRequired(); + builder.Property(decision => decision.ContentHash).HasMaxLength(RevisionContentHash.HexLength).IsRequired(); + builder.Property(decision => decision.RequiresIndependentApproval).IsRequired(); + builder.HasIndex(decision => new { decision.RevisionId, decision.DecidedAt }); + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/LaboratoriesDbContext.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/LaboratoriesDbContext.cs new file mode 100644 index 0000000..4a2f44b --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/LaboratoriesDbContext.cs @@ -0,0 +1,21 @@ +using Microsoft.EntityFrameworkCore; +using Platform.Laboratories.Domain.Models; + +namespace Platform.Laboratories.Infrastructure.Persistence; + +public sealed class LaboratoriesDbContext(DbContextOptions options) + : DbContext(options) +{ + public const string SchemaName = "laboratories"; + + public DbSet LabRevisions => Set(); + public DbSet CoursePublishPolicies => Set(); + public DbSet RevisionDecisions => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + modelBuilder.HasDefaultSchema(SchemaName); + modelBuilder.ApplyConfigurationsFromAssembly(typeof(LaboratoriesDbContext).Assembly); + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/LaboratoriesDbContextFactory.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/LaboratoriesDbContextFactory.cs new file mode 100644 index 0000000..8858800 --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/LaboratoriesDbContextFactory.cs @@ -0,0 +1,18 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Platform.Laboratories.Infrastructure.Persistence; + +public sealed class LaboratoriesDbContextFactory : IDesignTimeDbContextFactory +{ + public LaboratoriesDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("ConnectionStrings__Laboratories"); + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, postgres => postgres.MigrationsHistoryTable( + "__EFMigrationsHistory", LaboratoriesDbContext.SchemaName)) + .Options; + + return new LaboratoriesDbContext(options); + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/AddRevisionState.Designer.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/AddRevisionState.Designer.cs new file mode 100644 index 0000000..3ac964b --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/AddRevisionState.Designer.cs @@ -0,0 +1,131 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Platform.Laboratories.Infrastructure.Persistence; + +#nullable disable + +namespace Platform.Laboratories.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(LaboratoriesDbContext))] + [Migration("20261101222323_AddRevisionState")] + partial class AddRevisionState + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("laboratories") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Platform.Laboratories.Domain.Models.CoursePublishPolicy", b => + { + b.Property("CourseId") + .HasColumnType("uuid"); + + b.Property("RequiresIndependentApproval") + .HasColumnType("boolean"); + + b.HasKey("CourseId"); + + b.ToTable("CoursePublishPolicies", "laboratories", t => + { + t.HasCheckConstraint("CK_CoursePublishPolicies_CourseId", "\"CourseId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + }); + }); + + modelBuilder.Entity("Platform.Laboratories.Domain.Models.LabRevision", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CourseId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("LabRevisions", "laboratories", t => + { + t.HasCheckConstraint("CK_LabRevisions_AuthorId", "\"AuthorId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_LabRevisions_ContentHash", "\"ContentHash\" ~ '^[0-9a-f]{64}$'"); + + t.HasCheckConstraint("CK_LabRevisions_CourseId", "\"CourseId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_LabRevisions_Id", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_LabRevisions_RowVersion", "\"RowVersion\" > 0"); + + t.HasCheckConstraint("CK_LabRevisions_Status", "\"Status\" IN (0, 1, 2, 3)"); + }); + }); + + modelBuilder.Entity("Platform.Laboratories.Domain.Models.RevisionDecisionRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Decision") + .HasColumnType("integer"); + + b.Property("RequiresIndependentApproval") + .HasColumnType("boolean"); + + b.Property("ReviewerId") + .HasColumnType("uuid"); + + b.Property("RevisionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RevisionId", "DecidedAt"); + + b.ToTable("RevisionDecisions", "laboratories", t => + { + t.HasCheckConstraint("CK_RevisionDecisions_ContentHash", "\"ContentHash\" ~ '^[0-9a-f]{64}$'"); + + t.HasCheckConstraint("CK_RevisionDecisions_Decision", "\"Decision\" IN (1, 2)"); + + t.HasCheckConstraint("CK_RevisionDecisions_Id", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_RevisionDecisions_ReviewerId", "\"ReviewerId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_RevisionDecisions_RevisionId", "\"RevisionId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/AddRevisionState.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/AddRevisionState.cs new file mode 100644 index 0000000..0f415c4 --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/AddRevisionState.cs @@ -0,0 +1,49 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Platform.Laboratories.Infrastructure.Persistence.Migrations +{ + public partial class AddRevisionState : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "LabRevisions", + schema: "laboratories", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + CourseId = table.Column(type: "uuid", nullable: false), + AuthorId = table.Column(type: "uuid", nullable: false), + Status = table.Column(type: "integer", nullable: false), + ContentHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + RowVersion = table.Column(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_LabRevisions", x => x.Id); + table.CheckConstraint("CK_LabRevisions_AuthorId", "\"AuthorId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.CheckConstraint("CK_LabRevisions_ContentHash", "\"ContentHash\" ~ '^[0-9a-f]{64}$'"); + table.CheckConstraint("CK_LabRevisions_CourseId", "\"CourseId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.CheckConstraint("CK_LabRevisions_Id", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.CheckConstraint("CK_LabRevisions_RowVersion", "\"RowVersion\" > 0"); + table.CheckConstraint("CK_LabRevisions_Status", "\"Status\" IN (0, 1, 2, 3)"); + }); + + migrationBuilder.CreateIndex( + name: "IX_LabRevisions_CourseId", + schema: "laboratories", + table: "LabRevisions", + column: "CourseId"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "LabRevisions", + schema: "laboratories"); + } + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/CreateReviewTables.Designer.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/CreateReviewTables.Designer.cs new file mode 100644 index 0000000..c3b5cee --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/CreateReviewTables.Designer.cs @@ -0,0 +1,88 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Platform.Laboratories.Infrastructure.Persistence; + +#nullable disable + +namespace Platform.Laboratories.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(LaboratoriesDbContext))] + [Migration("20261101214537_CreateReviewTables")] + partial class CreateReviewTables + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("laboratories") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Platform.Laboratories.Domain.Models.CoursePublishPolicy", b => + { + b.Property("CourseId") + .HasColumnType("uuid"); + + b.Property("RequiresIndependentApproval") + .HasColumnType("boolean"); + + b.HasKey("CourseId"); + + b.ToTable("CoursePublishPolicies", "laboratories", t => + { + t.HasCheckConstraint("CK_CoursePublishPolicies_CourseId", "\"CourseId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + }); + }); + + modelBuilder.Entity("Platform.Laboratories.Domain.Models.RevisionDecisionRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Decision") + .HasColumnType("integer"); + + b.Property("RequiresIndependentApproval") + .HasColumnType("boolean"); + + b.Property("ReviewerId") + .HasColumnType("uuid"); + + b.Property("RevisionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RevisionId", "DecidedAt"); + + b.ToTable("RevisionDecisions", "laboratories", t => + { + t.HasCheckConstraint("CK_RevisionDecisions_ContentHash", "\"ContentHash\" ~ '^[0-9a-f]{64}$'"); + + t.HasCheckConstraint("CK_RevisionDecisions_Decision", "\"Decision\" IN (1, 2)"); + + t.HasCheckConstraint("CK_RevisionDecisions_Id", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_RevisionDecisions_ReviewerId", "\"ReviewerId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_RevisionDecisions_RevisionId", "\"RevisionId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/CreateReviewTables.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/CreateReviewTables.cs new file mode 100644 index 0000000..4efbf04 --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/CreateReviewTables.cs @@ -0,0 +1,70 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Platform.Laboratories.Infrastructure.Persistence.Migrations +{ + public partial class CreateReviewTables : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.EnsureSchema( + name: "laboratories"); + + migrationBuilder.CreateTable( + name: "CoursePublishPolicies", + schema: "laboratories", + columns: table => new + { + CourseId = table.Column(type: "uuid", nullable: false), + RequiresIndependentApproval = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CoursePublishPolicies", x => x.CourseId); + table.CheckConstraint("CK_CoursePublishPolicies_CourseId", "\"CourseId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + }); + + migrationBuilder.CreateTable( + name: "RevisionDecisions", + schema: "laboratories", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + RevisionId = table.Column(type: "uuid", nullable: false), + ReviewerId = table.Column(type: "uuid", nullable: false), + Decision = table.Column(type: "integer", nullable: false), + DecidedAt = table.Column(type: "timestamp with time zone", nullable: false), + ContentHash = table.Column(type: "character varying(64)", maxLength: 64, nullable: false), + RequiresIndependentApproval = table.Column(type: "boolean", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_RevisionDecisions", x => x.Id); + table.CheckConstraint("CK_RevisionDecisions_ContentHash", "\"ContentHash\" ~ '^[0-9a-f]{64}$'"); + table.CheckConstraint("CK_RevisionDecisions_Decision", "\"Decision\" IN (1, 2)"); + table.CheckConstraint("CK_RevisionDecisions_Id", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.CheckConstraint("CK_RevisionDecisions_ReviewerId", "\"ReviewerId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + table.CheckConstraint("CK_RevisionDecisions_RevisionId", "\"RevisionId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + }); + + migrationBuilder.CreateIndex( + name: "IX_RevisionDecisions_RevisionId_DecidedAt", + schema: "laboratories", + table: "RevisionDecisions", + columns: new[] { "RevisionId", "DecidedAt" }); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CoursePublishPolicies", + schema: "laboratories"); + + migrationBuilder.DropTable( + name: "RevisionDecisions", + schema: "laboratories"); + } + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/LaboratoriesDbContextModelSnapshot.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/LaboratoriesDbContextModelSnapshot.cs new file mode 100644 index 0000000..2d11969 --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/Migrations/LaboratoriesDbContextModelSnapshot.cs @@ -0,0 +1,129 @@ +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using Platform.Laboratories.Infrastructure.Persistence; + +#nullable disable + +namespace Platform.Laboratories.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(LaboratoriesDbContext))] + partial class LaboratoriesDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("laboratories") + .HasAnnotation("ProductVersion", "10.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Platform.Laboratories.Domain.Models.CoursePublishPolicy", b => + { + b.Property("CourseId") + .HasColumnType("uuid"); + + b.Property("RequiresIndependentApproval") + .HasColumnType("boolean"); + + b.HasKey("CourseId"); + + b.ToTable("CoursePublishPolicies", "laboratories", t => + { + t.HasCheckConstraint("CK_CoursePublishPolicies_CourseId", "\"CourseId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + }); + }); + + modelBuilder.Entity("Platform.Laboratories.Domain.Models.LabRevision", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("CourseId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .HasColumnType("bigint"); + + b.Property("Status") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CourseId"); + + b.ToTable("LabRevisions", "laboratories", t => + { + t.HasCheckConstraint("CK_LabRevisions_AuthorId", "\"AuthorId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_LabRevisions_ContentHash", "\"ContentHash\" ~ '^[0-9a-f]{64}$'"); + + t.HasCheckConstraint("CK_LabRevisions_CourseId", "\"CourseId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_LabRevisions_Id", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_LabRevisions_RowVersion", "\"RowVersion\" > 0"); + + t.HasCheckConstraint("CK_LabRevisions_Status", "\"Status\" IN (0, 1, 2, 3)"); + }); + }); + + modelBuilder.Entity("Platform.Laboratories.Domain.Models.RevisionDecisionRecord", b => + { + b.Property("Id") + .HasColumnType("uuid"); + + b.Property("ContentHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Decision") + .HasColumnType("integer"); + + b.Property("RequiresIndependentApproval") + .HasColumnType("boolean"); + + b.Property("ReviewerId") + .HasColumnType("uuid"); + + b.Property("RevisionId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RevisionId", "DecidedAt"); + + b.ToTable("RevisionDecisions", "laboratories", t => + { + t.HasCheckConstraint("CK_RevisionDecisions_ContentHash", "\"ContentHash\" ~ '^[0-9a-f]{64}$'"); + + t.HasCheckConstraint("CK_RevisionDecisions_Decision", "\"Decision\" IN (1, 2)"); + + t.HasCheckConstraint("CK_RevisionDecisions_Id", "\"Id\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_RevisionDecisions_ReviewerId", "\"ReviewerId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + + t.HasCheckConstraint("CK_RevisionDecisions_RevisionId", "\"RevisionId\" <> '00000000-0000-0000-0000-000000000000'::uuid"); + }); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Persistence/RevisionDecisionStore.cs b/backend/Platform.Laboratories.Infrastructure/Persistence/RevisionDecisionStore.cs new file mode 100644 index 0000000..a0c45a2 --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Persistence/RevisionDecisionStore.cs @@ -0,0 +1,68 @@ +using Microsoft.EntityFrameworkCore; +using Platform.Laboratories.Domain.Contracts; +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Models; +using Platform.Laboratories.Domain.Rules; + +namespace Platform.Laboratories.Infrastructure.Persistence; + +public sealed class RevisionDecisionStore( + IDbContextFactory contexts, + TimeProvider clock) : IRevisionDecisionStore +{ + public async Task SaveDecisionAsync( + RevisionDecisionRequest request, + CancellationToken cancellationToken = default) + { + if (request is null || request.RevisionId == Guid.Empty || request.ReviewerId == Guid.Empty || + request.ExpectedVersion < 1 || !RevisionContentHash.IsValid(request.ExpectedContentHash) || + request.Decision is not (RevisionReviewDecision.Approved or RevisionReviewDecision.ReturnedForChanges)) + return new DecisionSaveResult.Failure(RevisionChangeOutcome.InvalidInput); + + await using var context = await contexts.CreateDbContextAsync(cancellationToken); + await using var transaction = await context.Database.BeginTransactionAsync(cancellationToken); + var revision = await context.LabRevisions.SingleOrDefaultAsync( + value => value.Id == request.RevisionId, cancellationToken); + if (revision is null) + return new DecisionSaveResult.Failure(RevisionChangeOutcome.RevisionNotFound); + + var policy = await context.CoursePublishPolicies + .FromSqlInterpolated($""" + SELECT "CourseId", "RequiresIndependentApproval" + FROM laboratories."CoursePublishPolicies" + WHERE "CourseId" = {revision.CourseId} + FOR SHARE + """) + .AsNoTracking().SingleOrDefaultAsync(cancellationToken); + if (policy is null) + return new DecisionSaveResult.Failure(RevisionChangeOutcome.PolicyMissing); + + var outcome = revision.Decide(request.ReviewerId, request.ExpectedVersion, + request.ExpectedContentHash, request.Decision, policy); + if (outcome != RevisionChangeOutcome.Applied) + return new DecisionSaveResult.Failure(outcome); + + var recordResult = RevisionDecisionRecord.Create( + Guid.NewGuid(), revision.Id, request.ReviewerId, request.Decision, + clock.GetUtcNow(), revision.ContentHash, policy.RequiresIndependentApproval); + if (recordResult is not RevisionDecisionRecord.CreationResult.Success created) + return new DecisionSaveResult.Failure(RevisionChangeOutcome.InvalidInput); + + context.RevisionDecisions.Add(created.Value); + try + { + await context.SaveChangesAsync(cancellationToken); + await transaction.CommitAsync(cancellationToken); + } + catch (DbUpdateConcurrencyException) + { + return new DecisionSaveResult.Failure(RevisionChangeOutcome.ConcurrencyConflict); + } + catch (DbUpdateException) + { + return new DecisionSaveResult.Failure(RevisionChangeOutcome.StorageFailure); + } + + return new DecisionSaveResult.Success(created.Value.Id, revision.RowVersion, revision.Status); + } +} diff --git a/backend/Platform.Laboratories.Infrastructure/Platform.Laboratories.Infrastructure.csproj b/backend/Platform.Laboratories.Infrastructure/Platform.Laboratories.Infrastructure.csproj new file mode 100644 index 0000000..52bc246 --- /dev/null +++ b/backend/Platform.Laboratories.Infrastructure/Platform.Laboratories.Infrastructure.csproj @@ -0,0 +1,20 @@ + + + net10.0 + enable + enable + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/backend/Platform.Laboratories.IntegrationTests/MigrationUpgradeTests.cs b/backend/Platform.Laboratories.IntegrationTests/MigrationUpgradeTests.cs new file mode 100644 index 0000000..6803d3b --- /dev/null +++ b/backend/Platform.Laboratories.IntegrationTests/MigrationUpgradeTests.cs @@ -0,0 +1,60 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql; +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Models; +using Platform.Laboratories.Infrastructure.Persistence; + +namespace Platform.Laboratories.IntegrationTests; + +public partial class PublicationDatabaseTests +{ + [Test] + public async Task UpgradeFromFirstMigration_PreservesPolicyAndDecisionHistory() + { + var databaseName = "upgrade_" + Guid.NewGuid().ToString("N"); + await using (var connection = new NpgsqlConnection(database.GetConnectionString())) + { + await connection.OpenAsync(); + await using var command = new NpgsqlCommand($"CREATE DATABASE \"{databaseName}\"", connection); + await command.ExecuteNonQueryAsync(); + } + var connectionString = new NpgsqlConnectionStringBuilder(database.GetConnectionString()) + { + Database = databaseName + }.ConnectionString; + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString, postgres => + postgres.MigrationsHistoryTable("__EFMigrationsHistory", LaboratoriesDbContext.SchemaName)) + .Options; + var courseId = Guid.NewGuid(); + var decisionId = Guid.NewGuid(); + await using (var previous = new LaboratoriesDbContext(options)) + { + var firstMigration = previous.Database.GetMigrations().First(); + await previous.GetService().MigrateAsync(firstMigration); + var policy = CoursePublishPolicy.Create(courseId, true); + var record = RevisionDecisionRecord.Create(decisionId, Guid.NewGuid(), Guid.NewGuid(), + RevisionReviewDecision.Approved, DateTimeOffset.UtcNow, ValidHash, true); + Assert.That(policy, Is.TypeOf()); + Assert.That(record, Is.TypeOf()); + previous.CoursePublishPolicies.Add(((CoursePublishPolicy.CreationResult.Success)policy).Value); + previous.RevisionDecisions.Add(((RevisionDecisionRecord.CreationResult.Success)record).Value); + await previous.SaveChangesAsync(); + } + + await using var upgraded = new LaboratoriesDbContext(options); + await upgraded.Database.MigrateAsync(); + var savedPolicy = await upgraded.CoursePublishPolicies.SingleAsync(value => value.CourseId == courseId); + var savedDecision = await upgraded.RevisionDecisions.SingleAsync(value => value.Id == decisionId); + Assert.Multiple(() => + { + Assert.That(savedPolicy.RequiresIndependentApproval, Is.True); + Assert.That(savedDecision.ContentHash, Is.EqualTo(ValidHash)); + Assert.That(savedDecision.Decision, Is.EqualTo(RevisionReviewDecision.Approved)); + }); + Assert.That(await upgraded.LabRevisions.CountAsync(), Is.Zero); + Assert.That(await upgraded.Database.GetPendingMigrationsAsync(), Is.Empty); + } +} diff --git a/backend/Platform.Laboratories.IntegrationTests/Platform.Laboratories.IntegrationTests.csproj b/backend/Platform.Laboratories.IntegrationTests/Platform.Laboratories.IntegrationTests.csproj new file mode 100644 index 0000000..7cd3b95 --- /dev/null +++ b/backend/Platform.Laboratories.IntegrationTests/Platform.Laboratories.IntegrationTests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + + + + + + + + diff --git a/backend/Platform.Laboratories.IntegrationTests/PublicationDatabaseTests.cs b/backend/Platform.Laboratories.IntegrationTests/PublicationDatabaseTests.cs new file mode 100644 index 0000000..8f7ba6a --- /dev/null +++ b/backend/Platform.Laboratories.IntegrationTests/PublicationDatabaseTests.cs @@ -0,0 +1,180 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Platform.Laboratories.Infrastructure.Composition; +using Npgsql; +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Models; +using Platform.Laboratories.Infrastructure.Persistence; +using Testcontainers.PostgreSql; + +namespace Platform.Laboratories.IntegrationTests; + +[TestFixture] +[Category("Integration")] +[NonParallelizable] +public partial class PublicationDatabaseTests +{ + private const string ValidHash = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + private readonly PostgreSqlContainer database = new PostgreSqlBuilder("postgres:17-alpine") + .WithDatabase("publication_tests") + .WithUsername("publication_tests") + .WithPassword(Guid.NewGuid().ToString("N")) + .Build(); + + private LaboratoriesDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(database.GetConnectionString(), postgres => + postgres.MigrationsHistoryTable("__EFMigrationsHistory", LaboratoriesDbContext.SchemaName)) + .Options; + return new LaboratoriesDbContext(options); + } + + [Test] + public async Task PersistenceRegistration_ResolvesContextAndConnectsToDatabase() + { + await using var provider = new ServiceCollection() + .AddLaboratoriesPersistence(database.GetConnectionString()) + .BuildServiceProvider(); + await using var scope = provider.CreateAsyncScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + Assert.That(await context.Database.CanConnectAsync(), Is.True); + Assert.That(await context.Database.GetPendingMigrationsAsync(), Is.Empty); + } + [OneTimeSetUp] + public async Task StartDatabase() + { + await database.StartAsync(); + await using var context = CreateContext(); + await context.Database.MigrateAsync(); + } + + [OneTimeTearDown] + public async Task StopDatabase() + { + await database.DisposeAsync(); + } + + [Test] + public async Task Migrate_WhenRepeated_DoesNotDuplicateMigrationHistory() + { + await using var context = CreateContext(); + var before = (await context.Database.GetAppliedMigrationsAsync()).ToArray(); + + await context.Database.MigrateAsync(); + + var after = (await context.Database.GetAppliedMigrationsAsync()).ToArray(); + Assert.Multiple(() => + { + Assert.That(before, Has.Length.EqualTo(2)); + Assert.That(after, Is.EqualTo(before)); + Assert.That(context.Database.HasPendingModelChanges(), Is.False); + }); + } + + [TestCase(true)] + [TestCase(false)] + public async Task Policy_CanBeSavedAndLoadedInNewContext(bool requiresIndependentApproval) + { + var courseId = Guid.NewGuid(); + var creation = CoursePublishPolicy.Create(courseId, requiresIndependentApproval); + Assert.That(creation, Is.TypeOf()); + var policy = ((CoursePublishPolicy.CreationResult.Success)creation).Value; + await using (var context = CreateContext()) + { + context.CoursePublishPolicies.Add(policy); + await context.SaveChangesAsync(); + } + + await using var reader = CreateContext(); + var saved = await reader.CoursePublishPolicies.SingleAsync(value => value.CourseId == courseId); + Assert.Multiple(() => + { + Assert.That(saved.CourseId, Is.EqualTo(courseId)); + Assert.That(saved.RequiresIndependentApproval, Is.EqualTo(requiresIndependentApproval)); + }); + } + + [TestCase(RevisionReviewDecision.Approved)] + [TestCase(RevisionReviewDecision.ReturnedForChanges)] + public async Task Decision_CanBeSavedAndLoadedInNewContext(RevisionReviewDecision decision) + { + var id = Guid.NewGuid(); + var revisionId = Guid.NewGuid(); + var reviewerId = Guid.NewGuid(); + var time = new DateTimeOffset(2026, 11, 1, 12, 0, 0, TimeSpan.FromHours(1)); + var creation = RevisionDecisionRecord.Create(id, revisionId, reviewerId, decision, time, ValidHash, true); + Assert.That(creation, Is.TypeOf()); + var record = ((RevisionDecisionRecord.CreationResult.Success)creation).Value; + await using (var context = CreateContext()) + { + context.RevisionDecisions.Add(record); + await context.SaveChangesAsync(); + } + + await using var reader = CreateContext(); + var saved = await reader.RevisionDecisions.SingleAsync(value => value.Id == id); + Assert.Multiple(() => + { + Assert.That(saved.RevisionId, Is.EqualTo(revisionId)); + Assert.That(saved.ReviewerId, Is.EqualTo(reviewerId)); + Assert.That(saved.Decision, Is.EqualTo(decision)); + Assert.That(saved.DecidedAt, Is.EqualTo(time)); + Assert.That(saved.DecidedAt.Offset, Is.EqualTo(TimeSpan.Zero)); + Assert.That(saved.ContentHash, Is.EqualTo(ValidHash)); + Assert.That(saved.RequiresIndependentApproval, Is.True); + }); + } + + [TestCase("decision", "CK_RevisionDecisions_Decision")] + [TestCase("hash", "CK_RevisionDecisions_ContentHash")] + [TestCase("revision", "CK_RevisionDecisions_RevisionId")] + public async Task DirectSql_WithInvalidDecisionData_IsRejectedByDatabase(string invalidField, string constraint) + { + await using var context = CreateContext(); + var id = Guid.NewGuid(); + var revisionId = invalidField == "revision" ? Guid.Empty : Guid.NewGuid(); + var reviewerId = Guid.NewGuid(); + var decision = invalidField == "decision" ? 99 : 1; + var hash = invalidField == "hash" ? "invalid" : ValidHash; + var time = DateTimeOffset.UtcNow; + var error = Assert.ThrowsAsync(async () => + { + await context.Database.ExecuteSqlInterpolatedAsync($""" + INSERT INTO laboratories."RevisionDecisions" + ("Id", "RevisionId", "ReviewerId", "Decision", "DecidedAt", "ContentHash", "RequiresIndependentApproval") + VALUES ({id}, {revisionId}, {reviewerId}, {decision}, {time}, {hash}, {true}) + """); + }); + Assert.That(error, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(error!.SqlState, Is.EqualTo(PostgresErrorCodes.CheckViolation)); + Assert.That(error.ConstraintName, Is.EqualTo(constraint)); + }); + Assert.That(await context.RevisionDecisions.AnyAsync(value => value.Id == id), Is.False); + } + + [Test] + public async Task Policy_DuplicateCourse_IsRejectedByDatabase() + { + var courseId = Guid.NewGuid(); + var creation = CoursePublishPolicy.Create(courseId, true); + Assert.That(creation, Is.TypeOf()); + await using (var context = CreateContext()) + { + context.CoursePublishPolicies.Add(((CoursePublishPolicy.CreationResult.Success)creation).Value); + await context.SaveChangesAsync(); + } + + await using var duplicateContext = CreateContext(); + var duplicateCreation = CoursePublishPolicy.Create(courseId, false); + Assert.That(duplicateCreation, Is.TypeOf()); + duplicateContext.CoursePublishPolicies.Add(((CoursePublishPolicy.CreationResult.Success)duplicateCreation).Value); + var error = Assert.ThrowsAsync(async () => { await duplicateContext.SaveChangesAsync(); }); + Assert.That(error, Is.Not.Null); + Assert.That(error!.InnerException, Is.TypeOf()); + Assert.That(((PostgresException)error.InnerException!).SqlState, Is.EqualTo(PostgresErrorCodes.UniqueViolation)); + } +} diff --git a/backend/Platform.Laboratories.IntegrationTests/ReviewDecisionTests.cs b/backend/Platform.Laboratories.IntegrationTests/ReviewDecisionTests.cs new file mode 100644 index 0000000..8f0b215 --- /dev/null +++ b/backend/Platform.Laboratories.IntegrationTests/ReviewDecisionTests.cs @@ -0,0 +1,204 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; +using Platform.Laboratories.Domain.Contracts; +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Models; +using Platform.Laboratories.Infrastructure.Persistence; + +namespace Platform.Laboratories.IntegrationTests; + +public partial class PublicationDatabaseTests +{ + private async Task<(Guid RevisionId, Guid AuthorId)> SeedReview(bool independent = true, bool includePolicy = true) + { + var courseId = Guid.NewGuid(); + var authorId = Guid.NewGuid(); + var revisionId = Guid.NewGuid(); + var creation = LabRevision.Create(revisionId, courseId, authorId, ValidHash); + Assert.That(creation, Is.TypeOf()); + var revision = ((LabRevision.CreationResult.Success)creation).Value; + Assert.That(revision.SubmitForReview(1), Is.EqualTo(RevisionChangeOutcome.Applied)); + await using var context = CreateContext(); + if (includePolicy) + { + var policy = CoursePublishPolicy.Create(courseId, independent); + Assert.That(policy, Is.TypeOf()); + context.CoursePublishPolicies.Add(((CoursePublishPolicy.CreationResult.Success)policy).Value); + } + context.LabRevisions.Add(revision); + await context.SaveChangesAsync(); + return (revisionId, authorId); + } + + private RevisionDecisionStore CreateStore(params IInterceptor[] interceptors) + { + var options = new DbContextOptionsBuilder() + .UseNpgsql(database.GetConnectionString(), postgres => + postgres.MigrationsHistoryTable("__EFMigrationsHistory", LaboratoriesDbContext.SchemaName)) + .AddInterceptors(interceptors).Options; + return new RevisionDecisionStore(new ReviewContextFactory(options), TimeProvider.System); + } + + [TestCase(true, true, false)] + [TestCase(true, false, true)] + [TestCase(false, true, true)] + public async Task Approval_AppliesCoursePolicyAndSavesHistoryAtomically( + bool independent, bool isAuthor, bool allowed) + { + var review = await SeedReview(independent); + var reviewerId = isAuthor ? review.AuthorId : Guid.NewGuid(); + var result = await CreateStore().SaveDecisionAsync(new RevisionDecisionRequest( + review.RevisionId, reviewerId, 2, ValidHash, RevisionReviewDecision.Approved)); + + await using var reader = CreateContext(); + var saved = await reader.LabRevisions.SingleAsync(value => value.Id == review.RevisionId); + var history = await reader.RevisionDecisions.Where(value => value.RevisionId == review.RevisionId).ToListAsync(); + if (allowed) + { + Assert.That(result, Is.TypeOf()); + Assert.Multiple(() => + { + Assert.That(saved.Status, Is.EqualTo(LabRevisionStatus.Published)); + Assert.That(saved.RowVersion, Is.EqualTo(3)); + Assert.That(history, Has.Count.EqualTo(1)); + Assert.That(history[0].ReviewerId, Is.EqualTo(reviewerId)); + Assert.That(history[0].ContentHash, Is.EqualTo(ValidHash)); + Assert.That(history[0].RequiresIndependentApproval, Is.EqualTo(independent)); + }); + } + else + { + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(new DecisionSaveResult.Failure(RevisionChangeOutcome.SelfApprovalForbidden))); + Assert.That(saved.Status, Is.EqualTo(LabRevisionStatus.InReview)); + Assert.That(saved.RowVersion, Is.EqualTo(2)); + Assert.That(history, Is.Empty); + }); + } + } + + [Test] + public async Task ReturnForChanges_SavesDraftStateAndDecision() + { + var review = await SeedReview(); + var result = await CreateStore().SaveDecisionAsync(new RevisionDecisionRequest( + review.RevisionId, Guid.NewGuid(), 2, ValidHash, RevisionReviewDecision.ReturnedForChanges)); + await using var reader = CreateContext(); + var saved = await reader.LabRevisions.SingleAsync(value => value.Id == review.RevisionId); + var history = await reader.RevisionDecisions.SingleAsync(value => value.RevisionId == review.RevisionId); + Assert.Multiple(() => + { + Assert.That(result, Is.TypeOf()); + Assert.That(saved.Status, Is.EqualTo(LabRevisionStatus.Draft)); + Assert.That(saved.RowVersion, Is.EqualTo(3)); + Assert.That(history.Decision, Is.EqualTo(RevisionReviewDecision.ReturnedForChanges)); + }); + } + + [TestCase("version", RevisionChangeOutcome.ConcurrencyConflict)] + [TestCase("hash", RevisionChangeOutcome.ContentChanged)] + [TestCase("policy", RevisionChangeOutcome.PolicyMissing)] + public async Task Decision_WithStaleOrMissingData_LeavesStateAndHistoryUnchanged( + string invalidField, RevisionChangeOutcome expected) + { + var review = await SeedReview(includePolicy: invalidField != "policy"); + var hash = invalidField == "hash" ? new string('a', 64) : ValidHash; + var version = invalidField == "version" ? 1 : 2; + var result = await CreateStore().SaveDecisionAsync(new RevisionDecisionRequest( + review.RevisionId, Guid.NewGuid(), version, hash, RevisionReviewDecision.Approved)); + await using var reader = CreateContext(); + var saved = await reader.LabRevisions.SingleAsync(value => value.Id == review.RevisionId); + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(new DecisionSaveResult.Failure(expected))); + Assert.That(saved.Status, Is.EqualTo(LabRevisionStatus.InReview)); + Assert.That(saved.RowVersion, Is.EqualTo(2)); + }); + Assert.That(await reader.RevisionDecisions.AnyAsync(value => value.RevisionId == review.RevisionId), Is.False); + } + + [Test] + public async Task ConcurrentDecisions_OnlyOneCommitsStateAndHistory() + { + var review = await SeedReview(); + var store = CreateStore(new ConcurrentSaveGate()); + var first = store.SaveDecisionAsync(new RevisionDecisionRequest( + review.RevisionId, Guid.NewGuid(), 2, ValidHash, RevisionReviewDecision.Approved)); + var second = store.SaveDecisionAsync(new RevisionDecisionRequest( + review.RevisionId, Guid.NewGuid(), 2, ValidHash, RevisionReviewDecision.ReturnedForChanges)); + + var results = await Task.WhenAll(first, second); + + Assert.That(results.Count(value => value is DecisionSaveResult.Success), Is.EqualTo(1)); + Assert.That(results.Count(value => value == new DecisionSaveResult.Failure(RevisionChangeOutcome.ConcurrencyConflict)), Is.EqualTo(1)); + var winner = (DecisionSaveResult.Success)results.Single(value => value is DecisionSaveResult.Success); + await using var reader = CreateContext(); + var saved = await reader.LabRevisions.SingleAsync(value => value.Id == review.RevisionId); + var history = await reader.RevisionDecisions.Where(value => value.RevisionId == review.RevisionId).ToListAsync(); + Assert.Multiple(() => + { + Assert.That(saved.RowVersion, Is.EqualTo(3)); + Assert.That(saved.Status, Is.EqualTo(winner.Status)); + Assert.That(history, Has.Count.EqualTo(1)); + Assert.That(history[0].Id, Is.EqualTo(winner.DecisionId)); + }); + } + + [Test] + public async Task FailedHistoryInsert_RollsBackRevisionChange() + { + var review = await SeedReview(); + var store = CreateStore(new InvalidHistoryWrite()); + var result = await store.SaveDecisionAsync(new RevisionDecisionRequest( + review.RevisionId, Guid.NewGuid(), 2, ValidHash, RevisionReviewDecision.Approved)); + await using var reader = CreateContext(); + var saved = await reader.LabRevisions.SingleAsync(value => value.Id == review.RevisionId); + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(new DecisionSaveResult.Failure(RevisionChangeOutcome.StorageFailure))); + Assert.That(saved.Status, Is.EqualTo(LabRevisionStatus.InReview)); + Assert.That(saved.RowVersion, Is.EqualTo(2)); + }); + Assert.That(await reader.RevisionDecisions.AnyAsync(value => value.RevisionId == review.RevisionId), Is.False); + } + + private sealed class ReviewContextFactory(DbContextOptions options) + : IDbContextFactory + { + public LaboratoriesDbContext CreateDbContext() => new(options); + } + + private sealed class ConcurrentSaveGate : SaveChangesInterceptor + { + private readonly TaskCompletionSource ready = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int arrivals; + + public override async ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (Interlocked.Increment(ref arrivals) == 2) + ready.TrySetResult(); + await ready.Task.WaitAsync(TimeSpan.FromSeconds(20), cancellationToken); + return result; + } + } + + private sealed class InvalidHistoryWrite : SaveChangesInterceptor + { + public override ValueTask> SavingChangesAsync( + DbContextEventData eventData, + InterceptionResult result, + CancellationToken cancellationToken = default) + { + if (eventData.Context is LaboratoriesDbContext context) + { + foreach (var entry in context.ChangeTracker.Entries()) + entry.Property(value => value.Decision).CurrentValue = (RevisionReviewDecision)99; + } + return ValueTask.FromResult(result); + } + } +} diff --git a/backend/Platform.Laboratories.Tests/CoursePublishPolicyTests.cs b/backend/Platform.Laboratories.Tests/CoursePublishPolicyTests.cs new file mode 100644 index 0000000..6458340 --- /dev/null +++ b/backend/Platform.Laboratories.Tests/CoursePublishPolicyTests.cs @@ -0,0 +1,53 @@ +using NUnit.Framework; +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Models; + +namespace Platform.Laboratories.Tests; + +public class CoursePublishPolicyTests +{ + [TestCase(true, true, RevisionApprovalOutcome.SelfApprovalForbidden)] + [TestCase(true, false, RevisionApprovalOutcome.Allowed)] + [TestCase(false, true, RevisionApprovalOutcome.Allowed)] + [TestCase(false, false, RevisionApprovalOutcome.Allowed)] + public void EvaluateApproval_ReturnsExpectedOutcome( + bool requiresIndependentApproval, + bool isAuthor, + RevisionApprovalOutcome expected) + { + var creation = CoursePublishPolicy.Create(Guid.NewGuid(), requiresIndependentApproval); + Assert.That(creation, Is.TypeOf()); + var policy = ((CoursePublishPolicy.CreationResult.Success)creation).Value; + + var result = policy.EvaluateApproval(isAuthor); + + Assert.That(result, Is.EqualTo(expected)); + } + + [TestCase(true)] + [TestCase(false)] + public void Create_WithValidCourseId_PreservesCourseAndRule(bool requiresIndependentApproval) + { + var courseId = Guid.NewGuid(); + + var result = CoursePublishPolicy.Create(courseId, requiresIndependentApproval); + + Assert.That(result, Is.TypeOf()); + var policy = ((CoursePublishPolicy.CreationResult.Success)result).Value; + Assert.Multiple(() => + { + Assert.That(policy.CourseId, Is.EqualTo(courseId)); + Assert.That(policy.RequiresIndependentApproval, Is.EqualTo(requiresIndependentApproval)); + }); + } + + [TestCase(true)] + [TestCase(false)] + public void Create_WithEmptyCourseId_ReturnsInvalidCourseId(bool requiresIndependentApproval) + { + var result = CoursePublishPolicy.Create(Guid.Empty, requiresIndependentApproval); + + Assert.That(result, Is.EqualTo(new CoursePublishPolicy.CreationResult.Failure( + CoursePublishPolicy.CreationError.InvalidCourseId))); + } +} diff --git a/backend/Platform.Laboratories.Tests/LabRevisionTests.cs b/backend/Platform.Laboratories.Tests/LabRevisionTests.cs new file mode 100644 index 0000000..86f03a4 --- /dev/null +++ b/backend/Platform.Laboratories.Tests/LabRevisionTests.cs @@ -0,0 +1,61 @@ +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Models; + +namespace Platform.Laboratories.Tests; + +public class LabRevisionTests +{ + private const string Hash = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + [TestCase("id")] + [TestCase("course")] + [TestCase("author")] + [TestCase("hash")] + public void Create_InvalidMetadata_ReturnsFailure(string invalidField) + { + var result = LabRevision.Create( + invalidField == "id" ? Guid.Empty : Guid.NewGuid(), + invalidField == "course" ? Guid.Empty : Guid.NewGuid(), + invalidField == "author" ? Guid.Empty : Guid.NewGuid(), + invalidField == "hash" ? "invalid" : Hash); + Assert.That(result, Is.EqualTo(new LabRevision.CreationResult.Failure(RevisionChangeOutcome.InvalidInput))); + } + + [Test] + public void SubmitThenArchive_RejectsInvalidTransitionWithoutChangingVersion() + { + var creation = LabRevision.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Hash); + Assert.That(creation, Is.TypeOf()); + var revision = ((LabRevision.CreationResult.Success)creation).Value; + Assert.Multiple(() => + { + Assert.That(revision.Status, Is.EqualTo(LabRevisionStatus.Draft)); + Assert.That(revision.RowVersion, Is.EqualTo(1)); + Assert.That(revision.SubmitForReview(1), Is.EqualTo(RevisionChangeOutcome.Applied)); + Assert.That(revision.Archive(2), Is.EqualTo(RevisionChangeOutcome.InvalidTransition)); + Assert.That(revision.Status, Is.EqualTo(LabRevisionStatus.InReview)); + Assert.That(revision.RowVersion, Is.EqualTo(2)); + }); + } + + [Test] + public void Decide_WithPolicyFromAnotherCourse_DoesNotChangeRevision() + { + var creation = LabRevision.Create(Guid.NewGuid(), Guid.NewGuid(), Guid.NewGuid(), Hash); + var policyCreation = CoursePublishPolicy.Create(Guid.NewGuid(), false); + Assert.That(creation, Is.TypeOf()); + Assert.That(policyCreation, Is.TypeOf()); + var revision = ((LabRevision.CreationResult.Success)creation).Value; + var policy = ((CoursePublishPolicy.CreationResult.Success)policyCreation).Value; + Assert.That(revision.SubmitForReview(1), Is.EqualTo(RevisionChangeOutcome.Applied)); + + var result = revision.Decide(Guid.NewGuid(), 2, Hash, RevisionReviewDecision.Approved, policy); + + Assert.Multiple(() => + { + Assert.That(result, Is.EqualTo(RevisionChangeOutcome.PolicyMismatch)); + Assert.That(revision.Status, Is.EqualTo(LabRevisionStatus.InReview)); + Assert.That(revision.RowVersion, Is.EqualTo(2)); + }); + } +} diff --git a/backend/Platform.Laboratories.Tests/LabRevisionTransitionsTests.cs b/backend/Platform.Laboratories.Tests/LabRevisionTransitionsTests.cs new file mode 100644 index 0000000..5d22282 --- /dev/null +++ b/backend/Platform.Laboratories.Tests/LabRevisionTransitionsTests.cs @@ -0,0 +1,37 @@ +using NUnit.Framework; +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Rules; + +namespace Platform.Laboratories.Tests; + +public class LabRevisionTransitionsTests +{ + [TestCase(LabRevisionStatus.Draft, LabRevisionStatus.Draft, false)] + [TestCase(LabRevisionStatus.Draft, LabRevisionStatus.InReview, true)] + [TestCase(LabRevisionStatus.Draft, LabRevisionStatus.Published, false)] + [TestCase(LabRevisionStatus.Draft, LabRevisionStatus.Archived, false)] + + [TestCase(LabRevisionStatus.InReview, LabRevisionStatus.Draft, true)] + [TestCase(LabRevisionStatus.InReview, LabRevisionStatus.InReview, false)] + [TestCase(LabRevisionStatus.InReview, LabRevisionStatus.Published, true)] + [TestCase(LabRevisionStatus.InReview, LabRevisionStatus.Archived, false)] + + [TestCase(LabRevisionStatus.Published, LabRevisionStatus.Draft, false)] + [TestCase(LabRevisionStatus.Published, LabRevisionStatus.InReview, false)] + [TestCase(LabRevisionStatus.Published, LabRevisionStatus.Published, false)] + [TestCase(LabRevisionStatus.Published, LabRevisionStatus.Archived, true)] + + [TestCase(LabRevisionStatus.Archived, LabRevisionStatus.Draft, false)] + [TestCase(LabRevisionStatus.Archived, LabRevisionStatus.InReview, false)] + [TestCase(LabRevisionStatus.Archived, LabRevisionStatus.Published, false)] + [TestCase(LabRevisionStatus.Archived, LabRevisionStatus.Archived, false)] + public void CanTransition_ReturnsExpectedResult( + LabRevisionStatus from, + LabRevisionStatus to, + bool expected) + { + var result = LabRevisionTransitions.CanTransition(from, to); + + Assert.That(result, Is.EqualTo(expected)); + } +} \ No newline at end of file diff --git a/backend/Platform.Laboratories.Tests/PersistenceModelTests.cs b/backend/Platform.Laboratories.Tests/PersistenceModelTests.cs new file mode 100644 index 0000000..58150cf --- /dev/null +++ b/backend/Platform.Laboratories.Tests/PersistenceModelTests.cs @@ -0,0 +1,50 @@ +using Microsoft.EntityFrameworkCore; +using Platform.Laboratories.Domain.Models; +using Platform.Laboratories.Infrastructure.Persistence; + +namespace Platform.Laboratories.Tests; + +public class PersistenceModelTests +{ + private static LaboratoriesDbContext CreateContext() + { + var options = new DbContextOptionsBuilder() + .UseNpgsql("Host=localhost;Database=model_validation_only", postgres => + postgres.MigrationsHistoryTable("__EFMigrationsHistory", LaboratoriesDbContext.SchemaName)) + .Options; + return new LaboratoriesDbContext(options); + } + + [Test] + public void Model_MapsReadOnlyPolicyAndRevisionConcurrencyToken() + { + using var context = CreateContext(); + var policy = context.Model.FindEntityType(typeof(CoursePublishPolicy)); + Assert.That(policy, Is.Not.Null); + Assert.Multiple(() => + { + Assert.That(policy!.FindProperty(nameof(CoursePublishPolicy.CourseId)), Is.Not.Null); + Assert.That(policy.FindProperty(nameof(CoursePublishPolicy.RequiresIndependentApproval)), Is.Not.Null); + Assert.That(context.Model.FindEntityType(typeof(LabRevision))?.FindProperty(nameof(LabRevision.RowVersion))?.IsConcurrencyToken, Is.True); + }); + } + + [Test] + public void GenerateCreateScript_IncludesPostgresConstraintsAndSeparateSchema() + { + using var context = CreateContext(); + + var sql = context.Database.GenerateCreateScript(); + + Assert.Multiple(() => + { + Assert.That(sql, Does.Contain("laboratories.\"CoursePublishPolicies\"")); + Assert.That(sql, Does.Contain("laboratories.\"RevisionDecisions\"")); + Assert.That(sql, Does.Contain("CK_RevisionDecisions_ContentHash")); + Assert.That(sql, Does.Contain("^[0-9a-f]{64}$")); + Assert.That(sql, Does.Contain("CK_RevisionDecisions_Decision")); + Assert.That(sql, Does.Contain("timestamp with time zone")); + Assert.That(sql, Does.Not.Contain("FOREIGN KEY")); + }); + } +} diff --git a/backend/Platform.Laboratories.Tests/Platform.Laboratories.Tests.csproj b/backend/Platform.Laboratories.Tests/Platform.Laboratories.Tests.csproj new file mode 100644 index 0000000..90e18c1 --- /dev/null +++ b/backend/Platform.Laboratories.Tests/Platform.Laboratories.Tests.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + latest + enable + enable + false + + + + + + + + + + + + + + + + + + + + diff --git a/backend/Platform.Laboratories.Tests/RevisionConcurrencyTests.cs b/backend/Platform.Laboratories.Tests/RevisionConcurrencyTests.cs new file mode 100644 index 0000000..03a92e5 --- /dev/null +++ b/backend/Platform.Laboratories.Tests/RevisionConcurrencyTests.cs @@ -0,0 +1,27 @@ +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Rules; + +namespace Platform.Laboratories.Tests; + +public class RevisionConcurrencyTests +{ + [TestCase(1L, 1L, RevisionConcurrencyOutcome.Allowed)] + [TestCase(3L, 3L, RevisionConcurrencyOutcome.Allowed)] + [TestCase(long.MaxValue, long.MaxValue, RevisionConcurrencyOutcome.Allowed)] + [TestCase(3L, 4L, RevisionConcurrencyOutcome.ConcurrencyConflict)] + [TestCase(4L, 3L, RevisionConcurrencyOutcome.ConcurrencyConflict)] + [TestCase(0L, 1L, RevisionConcurrencyOutcome.InvalidVersion)] + [TestCase(1L, 0L, RevisionConcurrencyOutcome.InvalidVersion)] + [TestCase(-1L, 1L, RevisionConcurrencyOutcome.InvalidVersion)] + [TestCase(1L, -1L, RevisionConcurrencyOutcome.InvalidVersion)] + [TestCase(0L, 0L, RevisionConcurrencyOutcome.InvalidVersion)] + public void Evaluate_ReturnsExpectedOutcome( + long expectedVersion, + long currentVersion, + RevisionConcurrencyOutcome expected) + { + var result = RevisionConcurrency.Evaluate(expectedVersion, currentVersion); + + Assert.That(result, Is.EqualTo(expected)); + } +} diff --git a/backend/Platform.Laboratories.Tests/RevisionContentHashTests.cs b/backend/Platform.Laboratories.Tests/RevisionContentHashTests.cs new file mode 100644 index 0000000..7d6d51e --- /dev/null +++ b/backend/Platform.Laboratories.Tests/RevisionContentHashTests.cs @@ -0,0 +1,52 @@ + +using System.Text; +using Platform.Laboratories.Domain.Rules; + +namespace Platform.Laboratories.Tests; + +public class RevisionContentHashTests +{ + private const string AbcHash = "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"; + + [Test] + public void Compute_KnownInput_MatchesSha256Vector() + { + var result = RevisionContentHash.Compute(Encoding.UTF8.GetBytes("abc")); + Assert.That(result, Is.EqualTo(new RevisionContentHash.ComputationResult.Success(AbcHash))); + } + + [Test] + public void Compute_IdenticalBytes_ReturnsIdenticalHash() + { + var first = RevisionContentHash.Compute(Encoding.UTF8.GetBytes("snapshot")); + var second = RevisionContentHash.Compute(Encoding.UTF8.GetBytes("snapshot")); + Assert.That(first, Is.EqualTo(second)); + } + + [Test] + public void Compute_ChangedBytes_ChangesHash() + { + var original = RevisionContentHash.Compute(Encoding.UTF8.GetBytes("abc")); + var changed = RevisionContentHash.Compute(Encoding.UTF8.GetBytes("abd")); + Assert.That(changed, Is.Not.EqualTo(original)); + } + + [Test] + public void Compute_EmptySnapshot_ReturnsFailure() + { + var result = RevisionContentHash.Compute(ReadOnlySpan.Empty); + Assert.That(result, Is.EqualTo(new RevisionContentHash.ComputationResult.Failure( + RevisionContentHash.ComputationError.EmptySnapshot))); + } + + [TestCase(AbcHash, true)] + [TestCase("", false)] + [TestCase(" ", false)] + [TestCase("content-hash", false)] + [TestCase("BA7816BF8F01CFEA414140DE5DAE2223B00361A396177A9CB410FF61F20015AD", false)] + [TestCase("ga7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", false)] + public void IsValid_RequiresCanonicalLowercaseSha256(string hash, bool expected) + { + Assert.That(RevisionContentHash.IsValid(hash), Is.EqualTo(expected)); + } +} diff --git a/backend/Platform.Laboratories.Tests/RevisionDecisionRecordTests.cs b/backend/Platform.Laboratories.Tests/RevisionDecisionRecordTests.cs new file mode 100644 index 0000000..2e0dd3d --- /dev/null +++ b/backend/Platform.Laboratories.Tests/RevisionDecisionRecordTests.cs @@ -0,0 +1,56 @@ +using Platform.Laboratories.Domain.Enums; +using Platform.Laboratories.Domain.Models; + +namespace Platform.Laboratories.Tests; + +public class RevisionDecisionRecordTests +{ + [TestCase(RevisionReviewDecision.Approved)] + [TestCase(RevisionReviewDecision.ReturnedForChanges)] + public void Create_WithValidData_PreservesDecisionAndPolicy(RevisionReviewDecision decision) + { + var id = Guid.NewGuid(); + var revisionId = Guid.NewGuid(); + var reviewerId = Guid.NewGuid(); + var time = new DateTimeOffset(2026, 11, 1, 12, 0, 0, TimeSpan.FromHours(1)); + var result = RevisionDecisionRecord.Create( + id, revisionId, reviewerId, decision, time, "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", true); + + Assert.That(result, Is.TypeOf()); + var value = ((RevisionDecisionRecord.CreationResult.Success)result).Value; + Assert.Multiple(() => + { + Assert.That(value.Id, Is.EqualTo(id)); + Assert.That(value.RevisionId, Is.EqualTo(revisionId)); + Assert.That(value.ReviewerId, Is.EqualTo(reviewerId)); + Assert.That(value.Decision, Is.EqualTo(decision)); + Assert.That(value.DecidedAt, Is.EqualTo(time)); + Assert.That(value.DecidedAt.Offset, Is.EqualTo(TimeSpan.Zero)); + Assert.That(value.ContentHash, Is.EqualTo("ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad")); + Assert.That(value.RequiresIndependentApproval, Is.True); + }); + } + + [TestCase("id", RevisionDecisionRecord.CreationError.InvalidId)] + [TestCase("revision", RevisionDecisionRecord.CreationError.InvalidRevisionId)] + [TestCase("reviewer", RevisionDecisionRecord.CreationError.InvalidReviewerId)] + [TestCase("unspecified", RevisionDecisionRecord.CreationError.InvalidDecision)] + [TestCase("unknown", RevisionDecisionRecord.CreationError.InvalidDecision)] + [TestCase("time", RevisionDecisionRecord.CreationError.InvalidDecisionTime)] + [TestCase("hash", RevisionDecisionRecord.CreationError.InvalidContentHash)] + public void Create_WithInvalidData_ReturnsSpecificFailure( + string invalidField, RevisionDecisionRecord.CreationError expected) + { + var result = RevisionDecisionRecord.Create( + invalidField == "id" ? Guid.Empty : Guid.NewGuid(), + invalidField == "revision" ? Guid.Empty : Guid.NewGuid(), + invalidField == "reviewer" ? Guid.Empty : Guid.NewGuid(), + invalidField == "unspecified" ? RevisionReviewDecision.NotSpecified : + invalidField == "unknown" ? (RevisionReviewDecision)99 : RevisionReviewDecision.Approved, + invalidField == "time" ? default : DateTimeOffset.UtcNow, + invalidField == "hash" ? " " : "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + true); + + Assert.That(result, Is.EqualTo(new RevisionDecisionRecord.CreationResult.Failure(expected))); + } +} diff --git a/backend/SecurityLab.slnx b/backend/SecurityLab.slnx index 2f30a35..4e82fb5 100644 --- a/backend/SecurityLab.slnx +++ b/backend/SecurityLab.slnx @@ -3,9 +3,16 @@ + + + + + + + diff --git a/docs/components/REV-05.md b/docs/components/REV-05.md new file mode 100644 index 0000000..4653e72 --- /dev/null +++ b/docs/components/REV-05.md @@ -0,0 +1,171 @@ +# REV-05 — dvostepeno odobravanje objave: domen i baza + +| Stavka | Vrednost | +| ------------ | ---------------------------------------------------------------------------------------------- | +| Student | Matija Brkusanin | +| Broj indeksa | PR101/2023 | +| Tim | Tim 4 | +| Zahtev | REV-05 — dvostepeno odobrenje objave | +| Zaduženje | Domenski modeli, pravila i baza podataka | +| Status | Domen i baza implementirani i lokalno testirani; povezivanje sa ostalim komponentama predstoji | +| Projekti | `Platform.Laboratories.Domain`, `Platform.Laboratories.Infrastructure` | +| Grana | `feature/REV-05-domain-persistence` | + +## Moje zaduženje + +Moj deo obuhvata statuse revizije i dozvoljene prelaze, politiku objavljivanja za kurs, proveru samoodobravanja, hash sadržaja, kontrolu konkurentnih izmena i istoriju odluka. Za ove modele pripremljena su EF Core mapiranja, migracije i testovi. + +Osnovno pravilo REV-05 je da autor ne može da odobri sopstvenu reviziju kada politika kursa zahteva nezavisno odobrenje. + +Numerisanje i sadržaj snapshot-a pripadaju REV-01, a validacija tipa laboratorije i evaluator-a pripada REV-02. Frontend, endpoint-i i utvrđivanje korisničkih ovlašćenja nisu deo ovog zaduženja. U API je dodata samo registracija persistence servisa. + +## Ugovor za druge module + +Za čuvanje odluke koristi se `IRevisionDecisionStore`: + +```csharp +Task SaveDecisionAsync( + RevisionDecisionRequest request, + CancellationToken cancellationToken = default); +``` + +Zahtev sadrži ID revizije, ID pregledaoca, očekivanu verziju, hash pregledanog sadržaja i odluku. Uspešan rezultat vraća ID zapisa istorije, novi status i novu verziju. Neuspešan rezultat sadrži razlog odbijanja, na primer `SelfApprovalForbidden`, `ConcurrencyConflict` ili `ContentChanged`. + +Pozivalac mora prethodno proveriti ovlašćenja i obezbediti identitet pregledaoca iz pouzdanog serverskog izvora. Prosleđivanje tuđeg ID-ja iz klijentskog zahteva nije dokaz identiteta. Pre objavljivanja mora biti izvršena i validacija iz REV-02. + +## Implementacija + +Kod je podeljen na domen i infrastrukturu: + +- `Platform.Laboratories.Domain` sadrži poslovne modele i pravila, bez zavisnosti od EF Core-a, HTTP-a ili baze. +- `Platform.Laboratories.Infrastructure` sadrži PostgreSQL mapiranja, kontekst, migracije i čuvanje odluka. + +Najvažnije klase: + +| Klasa | Odgovornost | +| ------------------------ | -------------------------------------------------------------------- | +| `LabRevision` | Čuva metapodatke revizije i kontroliše promene statusa i verzije. | +| `LabRevisionTransitions` | Proverava dozvoljene parove početnog i ciljnog stanja. | +| `CoursePublishPolicy` | Čuva pravilo nezavisnog odobravanja za konkretan kurs. | +| `RevisionContentHash` | Računa SHA-256 nad bajtovima snapshot-a i proverava format hash-a. | +| `RevisionConcurrency` | Poredi očekivanu i trenutnu verziju. | +| `RevisionDecisionRecord` | Čuva pregledaoca, odluku, vreme, hash i primenjeno pravilo politike. | +| `LaboratoriesDbContext` | Povezuje modele sa PostgreSQL tabelama. | +| `RevisionDecisionStore` | U jednoj transakciji čuva promenu revizije i istoriju odluke. | + +### Stanja i dozvoljeni prelazi + +Prelazi prate dijagram 4.5.1 iz specifikacije: + +| Početno stanje | Akcija | Novo stanje | +| -------------- | ------------------ | ----------- | +| `Draft` | Predaja na pregled | `InReview` | +| `InReview` | Vraćanje na doradu | `Draft` | +| `InReview` | Odobravanje | `Published` | +| `Published` | Povlačenje | `Archived` | + +Nova revizija počinje u stanju `Draft`, sa `RowVersion = 1`. Status nema javni setter. Odbijena operacija ne menja stanje ni verziju. + +### Čuvanje odluke + +`RevisionDecisionStore` izvršava sledeći tok: + +1. Proverava ulazne podatke i učitava reviziju. +2. Učitava politiku njenog kursa i zaključava taj red za čitanje do kraja transakcije. +3. Proverava verziju, hash, dozvoljeni prelaz i samoodobravanje. +4. Priprema novi status, uvećava verziju i kreira zapis istorije sa serverskim vremenom. +5. Zajedno čuva promenu i istoriju, pa potvrđuje transakciju. + +`RowVersion` je aplikacioni `long`, mapiran kao EF concurrency token. Ako dva zahteva koriste istu verziju, samo jedan može da sačuva izmenu. Drugi dobija `ConcurrencyConflict`. Ako upis istorije ne uspe, poništava se i promena statusa. + +Očekivani domenski neuspeh vraća definisan rezultat, bez `return null` i bacanja domenskih izuzetaka. Konflikt koji EF prijavi pri upisu prevodi se u rezultat odbijanja. + +## Baza i migracije + +Tabele su u PostgreSQL šemi `laboratories`: + +- `CoursePublishPolicies` — jedna politika po kursu; `CourseId` je primarni ključ. +- `LabRevisions` — ID, kurs, autor, status, hash i verzija revizije. +- `RevisionDecisions` — istorija odluka, sa indeksom nad revizijom i vremenom. + +Migracije se nalaze u `Platform.Laboratories.Infrastructure/Persistence/Migrations`: + +- `CreateReviewTables` pravi politiku kursa i istoriju odluka. +- `AddRevisionState` dodaje metapodatke revizije sa verzijom i ograničenjima. + +Obe migracije su primenjene na lokalnu razvojnu bazu. Provereni su i instalacija na praznoj test bazi i nadogradnja prve migracije uz očuvanje podataka. `Down` metode uklanjaju odgovarajuće tabele i njihove podatke. + +`LabRevision` trenutno sadrži samo metapodatke potrebne ovom delu. Sadržaj i numerisanje revizije treba povezati sa modelom REV-01. Strani ključevi prema drugim modulima nisu dodati; tipove identifikatora i provere postojanja treba usaglasiti pri povezivanju. Trenutno su identifikatori `Guid`. + +## Dijagram arhitekture + +```mermaid +flowchart LR + API[Platform.Api] --> REG[Registracija servisa] + REG --> STORE[IRevisionDecisionStore] + STORE --> IMPL[RevisionDecisionStore] + IMPL --> DOM[Domenski modeli i pravila] + IMPL --> CTX[LaboratoriesDbContext] + CTX --> DB[(PostgreSQL)] +``` + +Registracija je u `Platform.Laboratories.Infrastructure/Composition`, a `Program.cs` poziva `AddLaboratoriesPersistence()`. Domenski projekat ne referencira API ili infrastrukturu. + +## Dijagram toka podataka + +```mermaid +flowchart LR + CALLER[Serverski pozivalac] -->|Odluka, identitet, verzija i hash| STORE[Čuvanje odluke] + STORE -->|Učitavanje| DATA[(Revizija i politika)] + DATA --> RULES[Provera domenskih pravila] + RULES -->|Odbijanje| RESULT[Definisan rezultat] + RULES -->|Dozvoljena promena| TX[Transakcija] + TX --> STATE[(Status i verzija)] + TX --> HISTORY[(Istorija odluka)] + TX --> RESULT + RESULT --> CALLER +``` + +Dijagram prikazuje implementirani domenski i persistence deo. Autentikacija, provera prava i HTTP zahtev pripadaju komponentama koje pozivaju ovaj ugovor. + +## Bezbednosne provere i ograničenja + +- Autor se poredi sa sačuvanim `AuthorId`, a pravilo se čita iz politike odgovarajućeg kursa. Nedostajuća politika odbija odluku. +- Promenjen hash ili zastarela verzija ne dozvoljavaju upis odluke. +- SHA-256 se računa nad tačnim bajtovima. Format i kompletan sadržaj snapshot-a treba usaglasiti sa REV-01/02; ova komponenta ne normalizuje JSON. +- Istorija čuva vrednost politike koja je važila pri odluci. Naknadna promena politike ne prepisuje taj zapis. +- Store dodaje nove zapise istorije; zasebna zabrana direktnog SQL menjanja ili brisanja istorije nije uvedena. +- Lozinka razvojne baze je u lokalnom, ignorisanom `.env` fajlu. API connection string je u user-secrets ili promenljivoj okruženja. +- Povezivanje sa serverskom autorizacijom i auditom objavljivanja ostaje odvojen korak. Testovi ovog dela ne potvrđuju ceo HTTP tok objavljivanja. + +## Demonstracija i testovi + +Iz korena repozitorijuma: + +```powershell +dotnet tool restore +dotnet build backend/Platform.Api/Platform.Api.csproj --configuration Release +dotnet test backend/Platform.Laboratories.Tests/Platform.Laboratories.Tests.csproj --configuration Release +dotnet test backend/Platform.Laboratories.IntegrationTests/Platform.Laboratories.IntegrationTests.csproj --configuration Release +``` + +Za integracione testove mora biti pokrenut Docker sa Linux kontejnerima. Testcontainers sam pravi poseban PostgreSQL kontejner na slobodnom portu i uklanja ga nakon testiranja. Razvojna baza i drugi projekti se ne koriste za ove testove. + +Poslednja lokalna provera: **61 jedinični/modelski i 20 integracionih testova prolazi**, a API build nema upozorenja ni greške. + +Za prikaz ključnih scenarija mogu se pokrenuti: + +- `Approval_AppliesCoursePolicyAndSavesHistoryAtomically` — zabrana samoodobravanja i primena politike. +- `ConcurrentDecisions_OnlyOneCommitsStateAndHistory` — dva zahteva nad istom verzijom. +- `FailedHistoryInsert_RollsBackRevisionChange` — poništavanje promene kada istorija ne može da se sačuva. +- `UpgradeFromFirstMigration_PreservesPolicyAndDecisionHistory` — očuvanje podataka pri nadogradnji. + +Razvojna baza se pokreće komandom `docker compose up -d --wait db`. Na novom računaru prvo treba napraviti lokalni `.env` prema `.env.example`, podesiti sopstvenu lozinku i connection string. Docker Compose čita `.env`, a API koristi `ConnectionStrings:Laboratories` kroz user-secrets u Development okruženju ili `ConnectionStrings__Laboratories` iz okruženja. + +Kada je promenljiva `ConnectionStrings__Laboratories` lokalno podešena, migracije se primenjuju komandom: + +```powershell +dotnet ef database update --project backend/Platform.Laboratories.Infrastructure --startup-project backend/Platform.Laboratories.Infrastructure --context LaboratoriesDbContext +``` + +U `.github/workflows/ci.yml` dodat je job za ove testove i proveru migracionog snapshot-a. Iste komande su proverene lokalno; prvo izvršavanje na GitHub-u predstoji nakon push-a i pull request-a. diff --git a/dotnet-tools.json b/dotnet-tools.json new file mode 100644 index 0000000..cce89d8 --- /dev/null +++ b/dotnet-tools.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.10", + "commands": [ + "dotnet-ef" + ], + "rollForward": false + } + } +} \ No newline at end of file