using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.QuestionBanks; using Tiku.Infrastructure.Persistence; namespace Tiku.IntegrationTests; public sealed class PersistenceModelTests { private static readonly DbContextOptions Options = new DbContextOptionsBuilder() .UseNpgsql("Host=localhost;Database=tiku_model_tests;Username=postgres") .Options; [Fact] public void Core_model_contains_expected_tables() { using var context = new TikuDbContext(Options); var tableNames = context.Model.GetEntityTypes() .Select(entity => entity.GetTableName()) .ToHashSet(StringComparer.Ordinal); Assert.Equal(55, tableNames.Count); Assert.Contains("tenants", tableNames); Assert.Contains("tenant_settings", tableNames); Assert.Contains("content_entries", tableNames); Assert.Contains("content_nodes", tableNames); Assert.Contains("question_collections", tableNames); Assert.Contains("practice_blueprints", tableNames); Assert.Contains("vocabulary_units", tableNames); Assert.Contains("vocabulary_words", tableNames); Assert.Contains("user_word_progress", tableNames); Assert.Contains("user_word_favorites", tableNames); Assert.Contains("handbook_subjects", tableNames); Assert.Contains("handbook_chapters", tableNames); Assert.Contains("handbook_entries", tableNames); Assert.Contains("question_type_groups", tableNames); Assert.Contains("subject_shares", tableNames); Assert.Contains("content_assets", tableNames); Assert.Contains("content_import_jobs", tableNames); Assert.Contains("content_import_items", tableNames); Assert.Contains("content_import_issues", tableNames); Assert.Contains("images", tableNames); Assert.Contains("app_assets", tableNames); Assert.Contains("video_explanations", tableNames); Assert.Contains("question_videos", tableNames); Assert.Contains("exam_dates", tableNames); Assert.Contains("reports", tableNames); Assert.Contains("report_status_events", tableNames); Assert.Contains("user_score_events", tableNames); Assert.Contains("practice_daily_usage", tableNames); Assert.Contains("practice_access_events", tableNames); Assert.Contains("practice_session_reports", tableNames); Assert.Contains("practice_session_report_sections", tableNames); Assert.Contains("dashboard_daily_stats", tableNames); Assert.Contains("revenue_daily_stats", tableNames); Assert.Contains("questions", tableNames); Assert.Contains("question_versions", tableNames); Assert.Contains("answer_records", tableNames); } [Fact] public void Content_node_path_is_mapped_to_ltree() { using var context = new TikuDbContext(Options); var path = context.Model.FindEntityType(typeof(ContentNode))! .FindProperty(nameof(ContentNode.Path))!; Assert.Equal("ltree", path.GetColumnType()); } [Fact] public void Collection_relations_include_tenant_in_foreign_keys() { using var context = new TikuDbContext(Options); var foreignKeys = context.Model.FindEntityType(typeof(QuestionCollection))! .GetForeignKeys() .Where(foreignKey => foreignKey.Properties.Count > 1) .Select(foreignKey => foreignKey.Properties .Select(property => property.Name) .ToArray()); Assert.All( foreignKeys, properties => Assert.Contains("TenantId", properties)); } [Theory] [InlineData(nameof(Question.Tags), "'[]'::jsonb")] public void Question_json_properties_are_mapped_to_jsonb( string propertyName, string expectedDefaultValueSql) { using var context = new TikuDbContext(Options); var property = context.Model.FindEntityType(typeof(Question))! .FindProperty(propertyName)!; Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); } [Fact] public void Question_relations_include_tenant_in_foreign_keys() { using var context = new TikuDbContext(Options); var foreignKeys = context.Model.FindEntityType(typeof(Question))! .GetForeignKeys() .Select(foreignKey => foreignKey.Properties .Select(property => property.Name) .ToArray()) .ToArray(); Assert.All( foreignKeys.Where(properties => properties.Length > 1), properties => Assert.Contains("TenantId", properties)); Assert.Contains( foreignKeys, properties => properties.SequenceEqual( ["TenantId", "Id", "CurrentVersionId"])); } [Theory] [InlineData(typeof(VocabularyWord), nameof(VocabularyWord.Tags), "'[]'::jsonb")] [InlineData(typeof(VocabularyWord), nameof(VocabularyWord.Metadata), "'{}'::jsonb")] [InlineData(typeof(HandbookSubject), nameof(HandbookSubject.MajorLegacyIds), "'[]'::jsonb")] [InlineData(typeof(HandbookEntry), nameof(HandbookEntry.Tags), "'[]'::jsonb")] [InlineData(typeof(QuestionTypeGroup), nameof(QuestionTypeGroup.Types), "'[]'::jsonb")] [InlineData(typeof(UserWordProgress), nameof(UserWordProgress.Metadata), "'{}'::jsonb")] public void Study_content_json_properties_are_mapped_to_jsonb( Type entityType, string propertyName, string expectedDefaultValueSql) { using var context = new TikuDbContext(Options); var property = context.Model.FindEntityType(entityType)! .FindProperty(propertyName)!; Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); } [Fact] public void User_word_progress_has_review_constraints_and_precision() { using var context = new TikuDbContext(Options); var entityType = context.GetService() .Model .FindEntityType(typeof(UserWordProgress))!; var checkConstraints = entityType.GetCheckConstraints() .Select(constraint => constraint.Name) .ToHashSet(StringComparer.Ordinal); var easeFactor = entityType.FindProperty(nameof(UserWordProgress.EaseFactor))!; Assert.Equal(4, easeFactor.GetPrecision()); Assert.Equal(2, easeFactor.GetScale()); Assert.Contains("ck_user_word_progress_review_count", checkConstraints); Assert.Contains("ck_user_word_progress_correct_streak", checkConstraints); Assert.Contains("ck_user_word_progress_ease_factor", checkConstraints); } [Fact] public void Study_content_relations_include_tenant_in_foreign_keys() { using var context = new TikuDbContext(Options); var entityTypes = new[] { typeof(VocabularyUnit), typeof(VocabularyWord), typeof(UserWordProgress), typeof(UserWordFavorite), typeof(HandbookSubject), typeof(HandbookChapter), typeof(HandbookEntry), typeof(QuestionTypeGroup), typeof(SubjectShare) }; var compositeForeignKeys = entityTypes .SelectMany(entityType => context.Model.FindEntityType(entityType)! .GetForeignKeys()) .Where(foreignKey => foreignKey.Properties.Count > 1) .Select(foreignKey => foreignKey.Properties .Select(property => property.Name) .ToArray()); Assert.All( compositeForeignKeys, properties => Assert.Contains("TenantId", properties)); } [Fact] public void Word_user_join_tables_have_tenant_scoped_unique_indexes() { using var context = new TikuDbContext(Options); AssertHasUniqueIndex( nameof(UserWordProgress.TenantId), nameof(UserWordProgress.UserId), nameof(UserWordProgress.WordId)); AssertHasUniqueIndex( nameof(UserWordFavorite.TenantId), nameof(UserWordFavorite.UserId), nameof(UserWordFavorite.WordId)); void AssertHasUniqueIndex(params string[] propertyNames) { var indexes = context.Model.FindEntityType(typeof(TEntity))!.GetIndexes(); Assert.Contains( indexes, index => index.IsUnique && index.Properties .Select(property => property.Name) .SequenceEqual(propertyNames)); } } [Theory] [InlineData(typeof(ContentAsset), nameof(ContentAsset.AccessRules), "'{}'::jsonb")] [InlineData(typeof(ContentAsset), nameof(ContentAsset.VerificationDetails), "'{}'::jsonb")] [InlineData(typeof(ContentAsset), nameof(ContentAsset.SecurityFlags), "'{}'::jsonb")] [InlineData(typeof(ContentImportJob), nameof(ContentImportJob.RawPayload), "'[]'::jsonb")] [InlineData(typeof(ContentImportItem), nameof(ContentImportItem.SourcePayload), "'{}'::jsonb")] [InlineData(typeof(ContentImportIssue), nameof(ContentImportIssue.Details), "'{}'::jsonb")] [InlineData(typeof(VideoExplanation), nameof(VideoExplanation.KnowledgeTags), "'[]'::jsonb")] [InlineData(typeof(QuestionVideo), nameof(QuestionVideo.Metadata), "'{}'::jsonb")] public void Asset_import_and_video_json_properties_are_mapped_to_jsonb( Type entityType, string propertyName, string expectedDefaultValueSql) { using var context = new TikuDbContext(Options); var property = context.Model.FindEntityType(entityType)! .FindProperty(propertyName)!; Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); } [Fact] public void Import_items_and_question_videos_have_expected_unique_indexes() { using var context = new TikuDbContext(Options); AssertHasUniqueIndex( nameof(ContentImportItem.JobId), nameof(ContentImportItem.RowNo)); AssertHasUniqueIndex( nameof(QuestionVideo.TenantId), nameof(QuestionVideo.QuestionId), nameof(QuestionVideo.VideoId), nameof(QuestionVideo.VideoType)); void AssertHasUniqueIndex(params string[] propertyNames) { var indexes = context.Model.FindEntityType(typeof(TEntity))!.GetIndexes(); Assert.Contains( indexes, index => index.IsUnique && index.Properties .Select(property => property.Name) .SequenceEqual(propertyNames)); } } [Fact] public void Asset_import_and_video_relations_include_tenant_in_foreign_keys() { using var context = new TikuDbContext(Options); var entityTypes = new[] { typeof(ContentAsset), typeof(ContentImportJob), typeof(ContentImportItem), typeof(ContentImportIssue), typeof(VideoExplanation), typeof(QuestionVideo) }; var compositeForeignKeys = entityTypes .SelectMany(entityType => context.Model.FindEntityType(entityType)! .GetForeignKeys()) .Where(foreignKey => foreignKey.Properties.Count > 1) .Select(foreignKey => foreignKey.Properties .Select(property => property.Name) .ToArray()); Assert.All( compositeForeignKeys, properties => Assert.Contains("TenantId", properties)); } [Theory] [InlineData(typeof(ContentNode), nameof(ContentNode.AccessRules), "'{}'::jsonb")] [InlineData(typeof(QuestionCollection), nameof(QuestionCollection.AccessRules), "'{}'::jsonb")] [InlineData(typeof(PracticeBlueprint), nameof(PracticeBlueprint.AccessRules), "'{}'::jsonb")] [InlineData(typeof(PracticeSession), nameof(PracticeSession.AccessSnapshot), "'{}'::jsonb")] [InlineData(typeof(Report), nameof(Report.Attachments), "'[]'::jsonb")] [InlineData(typeof(Report), nameof(Report.Metadata), "'{}'::jsonb")] [InlineData(typeof(PracticeSessionReport), nameof(PracticeSessionReport.QuestionResults), "'[]'::jsonb")] [InlineData(typeof(PracticeSessionReport), nameof(PracticeSessionReport.WrongQuestionIds), "'[]'::jsonb")] [InlineData(typeof(PracticeSessionReportSection), nameof(PracticeSessionReportSection.Metadata), "'{}'::jsonb")] [InlineData(typeof(PracticeDailyUsage), nameof(PracticeDailyUsage.Metadata), "'{}'::jsonb")] [InlineData(typeof(PracticeAccessEvent), nameof(PracticeAccessEvent.Metadata), "'{}'::jsonb")] public void Learning_report_json_properties_are_mapped_to_jsonb( Type entityType, string propertyName, string expectedDefaultValueSql) { using var context = new TikuDbContext(Options); var property = context.Model.FindEntityType(entityType)! .FindProperty(propertyName)!; Assert.Equal("jsonb", property.GetColumnType()); Assert.Equal(expectedDefaultValueSql, property.GetDefaultValueSql()); Assert.Equal(typeof(System.Text.Json.JsonElement), property.ClrType); } [Fact] public void Practice_usage_unique_scope_treats_nulls_as_not_distinct() { using var context = new TikuDbContext(Options); var index = context.GetService() .Model .FindEntityType(typeof(PracticeDailyUsage))! .GetIndexes() .Single(index => index.IsUnique && index.Properties.Select(property => property.Name).SequenceEqual([ nameof(PracticeDailyUsage.TenantId), nameof(PracticeDailyUsage.UserId), nameof(PracticeDailyUsage.UsageDate), nameof(PracticeDailyUsage.ScopeType), nameof(PracticeDailyUsage.ScopeId) ])); Assert.False(index.GetAreNullsDistinct()); } [Fact] public void Practice_report_scores_have_expected_precision() { using var context = new TikuDbContext(Options); var reportType = context.Model.FindEntityType(typeof(PracticeSessionReport))!; var score = reportType.FindProperty(nameof(PracticeSessionReport.Score))!; var accuracy = reportType.FindProperty(nameof(PracticeSessionReport.Accuracy))!; Assert.Equal(10, score.GetPrecision()); Assert.Equal(2, score.GetScale()); Assert.Equal(6, accuracy.GetPrecision()); Assert.Equal(4, accuracy.GetScale()); } }