using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Tiku.Domain.Commerce; using Tiku.Domain.Content; using Tiku.Domain.Learning; using Tiku.Domain.QuestionBanks; using Tiku.Domain.Tenancy; 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(79, 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("tenant_auth_providers", tableNames); Assert.Contains("sms_verification_codes", tableNames); Assert.Contains("auth_login_events", tableNames); Assert.Contains("auth_sessions", tableNames); Assert.Contains("sms_send_rate_limits", tableNames); Assert.Contains("tenant_role_templates", tableNames); Assert.Contains("tenant_classes", tableNames); Assert.Contains("tenant_class_members", tableNames); Assert.Contains("tenant_student_notes", tableNames); Assert.Contains("tenant_student_followups", tableNames); Assert.Contains("products", tableNames); Assert.Contains("svip_plans", tableNames); Assert.Contains("orders", tableNames); Assert.Contains("order_items", tableNames); Assert.Contains("payments", tableNames); Assert.Contains("payment_events", tableNames); Assert.Contains("entitlements", tableNames); Assert.Contains("code_batches", tableNames); Assert.Contains("activation_codes", tableNames); Assert.Contains("coupons", tableNames); Assert.Contains("coupon_redemptions", tableNames); Assert.Contains("tenant_payment_accounts", tableNames); Assert.Contains("tenant_subscriptions", tableNames); Assert.Contains("tenant_usage_records", 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()); } [Theory] [InlineData(typeof(TenantAuthProvider), nameof(TenantAuthProvider.ConfigPublic), "'{}'::jsonb")] [InlineData(typeof(SmsVerificationCode), nameof(SmsVerificationCode.Metadata), "'{}'::jsonb")] [InlineData(typeof(AuthLoginEvent), nameof(AuthLoginEvent.Metadata), "'{}'::jsonb")] [InlineData(typeof(AuthSession), nameof(AuthSession.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantRoleTemplate), nameof(TenantRoleTemplate.Permissions), "'{}'::jsonb")] [InlineData(typeof(TenantRoleTemplate), nameof(TenantRoleTemplate.DataScope), "'{}'::jsonb")] [InlineData(typeof(TenantClass), nameof(TenantClass.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantClassMember), nameof(TenantClassMember.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantStudentNote), nameof(TenantStudentNote.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantStudentFollowup), nameof(TenantStudentFollowup.Metadata), "'{}'::jsonb")] public void Auth_and_tenant_operations_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 Auth_and_class_tables_have_expected_unique_indexes() { using var context = new TikuDbContext(Options); AssertHasUniqueIndex( nameof(TenantAuthProvider.TenantId), nameof(TenantAuthProvider.Provider)); AssertHasUniqueIndex( nameof(TenantClassMember.TenantId), nameof(TenantClassMember.ClassId), nameof(TenantClassMember.UserId), nameof(TenantClassMember.MemberType)); 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(Product), nameof(Product.Tags), "'[]'::jsonb")] [InlineData(typeof(Product), nameof(Product.DetailImages), "'[]'::jsonb")] [InlineData(typeof(Order), nameof(Order.RawPayload), "'{}'::jsonb")] [InlineData(typeof(OrderItem), nameof(OrderItem.Metadata), "'{}'::jsonb")] [InlineData(typeof(Payment), nameof(Payment.RawPayload), "'{}'::jsonb")] [InlineData(typeof(PaymentEvent), nameof(PaymentEvent.Payload), "'{}'::jsonb")] [InlineData(typeof(Entitlement), nameof(Entitlement.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantPaymentAccount), nameof(TenantPaymentAccount.ConfigPublic), "'{}'::jsonb")] [InlineData(typeof(TenantSubscription), nameof(TenantSubscription.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantUsageRecord), nameof(TenantUsageRecord.Metadata), "'{}'::jsonb")] public void Commerce_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 Commerce_tables_have_expected_unique_indexes_and_money_types() { using var context = new TikuDbContext(Options); AssertHasUniqueIndex(nameof(Order.TenantId), nameof(Order.OrderNo)); AssertHasUniqueIndex(nameof(ActivationCode.TenantId), nameof(ActivationCode.Code)); AssertHasUniqueIndex( nameof(TenantPaymentAccount.TenantId), nameof(TenantPaymentAccount.Provider)); var amount = context.Model.FindEntityType(typeof(Order))! .FindProperty(nameof(Order.AmountCents))!; Assert.Equal(typeof(int), amount.ClrType); 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)); } } }