using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Metadata; using Tiku.Domain.Commerce; using Tiku.Domain.Content; using Tiku.Domain.Growth; using Tiku.Domain.Import; using Tiku.Domain.Learning; using Tiku.Domain.Operations; using Tiku.Domain.Platform; 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(124, 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("commerce_refund_requests", tableNames); Assert.Contains("commerce_refund_events", tableNames); Assert.Contains("commerce_reconciliation_batches", tableNames); Assert.Contains("commerce_reconciliation_items", tableNames); Assert.Contains("commerce_reconciliation_issues", tableNames); Assert.Contains("commerce_reconciliation_issue_events", tableNames); Assert.Contains("content_asset_access_events", tableNames); Assert.Contains("content_asset_security_scan_events", tableNames); Assert.Contains("question_bank_grants", tableNames); Assert.Contains("tenant_question_bank_adoptions", tableNames); Assert.Contains("ai_recommendation_reports", tableNames); Assert.Contains("referral_tracks", tableNames); Assert.Contains("referral_codes", tableNames); Assert.Contains("referral_leads", tableNames); Assert.Contains("referral_team_edges", tableNames); Assert.Contains("referral_qrcodes", tableNames); Assert.Contains("crm_config", tableNames); Assert.Contains("crm_webhook_queue", tableNames); Assert.Contains("crm_webhook_log", tableNames); Assert.Contains("tenant_commission_settings", tableNames); Assert.Contains("commission_settlements", tableNames); Assert.Contains("commission_settlement_items", tableNames); Assert.Contains("banners", tableNames); Assert.Contains("faqs", tableNames); Assert.Contains("announcements", tableNames); Assert.Contains("audit_logs", tableNames); Assert.Contains("user_notifications", tableNames); Assert.Contains("badges", tableNames); Assert.Contains("user_badges", tableNames); Assert.Contains("tenant_content_notifications", tableNames); Assert.Contains("tenant_theme_templates", tableNames); Assert.Contains("tenant_theme_configs", tableNames); Assert.Contains("platform_saas_plans", tableNames); Assert.Contains("tenant_billing_profiles", tableNames); Assert.Contains("tenant_invoices", tableNames); Assert.Contains("tenant_invoice_items", tableNames); Assert.Contains("tenant_invoice_payments", tableNames); Assert.Contains("tenant_invoice_reminders", tableNames); Assert.Contains("platform_audit_alert_rules", tableNames); Assert.Contains("platform_audit_alerts", tableNames); Assert.Contains("platform_dunning_notification_channels", tableNames); Assert.Contains("platform_dunning_notification_events", tableNames); Assert.Contains("pb_import_runs", tableNames); Assert.Contains("pb_raw_records", tableNames); Assert.Contains("pb_import_issues", 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.SecurityScanSummary), "'{}'::jsonb")] [InlineData(typeof(ContentAsset), nameof(ContentAsset.SecurityFlags), "'{}'::jsonb")] [InlineData(typeof(ContentAssetAccessEvent), nameof(ContentAssetAccessEvent.Metadata), "'{}'::jsonb")] [InlineData(typeof(ContentAssetSecurityScanEvent), nameof(ContentAssetSecurityScanEvent.Details), "'{}'::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")] [InlineData(typeof(QuestionBankGrant), nameof(QuestionBankGrant.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantQuestionBankAdoption), nameof(TenantQuestionBankAdoption.SourceSnapshot), "'{}'::jsonb")] [InlineData(typeof(TenantQuestionBankAdoption), nameof(TenantQuestionBankAdoption.Metadata), "'{}'::jsonb")] [InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.InputPayload), "'{}'::jsonb")] [InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.ContextPayload), "'{}'::jsonb")] [InlineData(typeof(AiRecommendationReport), nameof(AiRecommendationReport.ResultPayload), "'{}'::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)); AssertHasUniqueIndex( nameof(TenantQuestionBankAdoption.TenantId), nameof(TenantQuestionBankAdoption.SourceQuestionBankId)); var allowedPlanCodes = context.Model.FindEntityType(typeof(QuestionBankGrant))! .FindProperty(nameof(QuestionBankGrant.AllowedPlanCodes))!; Assert.Equal("text[]", allowedPlanCodes.GetColumnType()); var allowedTenantIds = context.Model.FindEntityType(typeof(QuestionBankGrant))! .FindProperty(nameof(QuestionBankGrant.AllowedTenantIds))!; Assert.Equal("uuid[]", allowedTenantIds.GetColumnType()); 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")] [InlineData(typeof(CommerceRefundRequest), nameof(CommerceRefundRequest.Metadata), "'{}'::jsonb")] [InlineData(typeof(CommerceRefundEvent), nameof(CommerceRefundEvent.Details), "'{}'::jsonb")] [InlineData(typeof(CommerceReconciliationBatch), nameof(CommerceReconciliationBatch.Metadata), "'{}'::jsonb")] [InlineData(typeof(CommerceReconciliationItem), nameof(CommerceReconciliationItem.Details), "'{}'::jsonb")] [InlineData(typeof(CommerceReconciliationIssue), nameof(CommerceReconciliationIssue.Metadata), "'{}'::jsonb")] [InlineData(typeof(CommerceReconciliationIssueEvent), nameof(CommerceReconciliationIssueEvent.Details), "'{}'::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)); AssertHasUniqueIndex( nameof(CommerceRefundRequest.TenantId), nameof(CommerceRefundRequest.RefundNo)); AssertHasUniqueIndex( nameof(CommerceReconciliationItem.BatchId), nameof(CommerceReconciliationItem.RowNo)); AssertHasUniqueIndex( nameof(CommerceReconciliationIssue.TenantId), nameof(CommerceReconciliationIssue.IssueNo)); AssertHasUniqueIndex( nameof(CommerceReconciliationIssue.TenantId), nameof(CommerceReconciliationIssue.ItemId)); var amount = context.Model.FindEntityType(typeof(Order))! .FindProperty(nameof(Order.AmountCents))!; Assert.Equal(typeof(int), amount.ClrType); var refundedAmount = context.Model.FindEntityType(typeof(Order))! .FindProperty(nameof(Order.RefundedAmountCents))!; Assert.Equal(typeof(int), refundedAmount.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)); } } [Theory] [InlineData(typeof(ReferralTrack), nameof(ReferralTrack.Metadata), "'{}'::jsonb")] [InlineData(typeof(ReferralCode), nameof(ReferralCode.Metadata), "'{}'::jsonb")] [InlineData(typeof(ReferralLead), nameof(ReferralLead.Metadata), "'{}'::jsonb")] [InlineData(typeof(ReferralTeamEdge), nameof(ReferralTeamEdge.Metadata), "'{}'::jsonb")] [InlineData(typeof(ReferralQrcode), nameof(ReferralQrcode.Metadata), "'{}'::jsonb")] [InlineData(typeof(CrmConfig), nameof(CrmConfig.AssignmentPool), "'[]'::jsonb")] [InlineData(typeof(CrmConfig), nameof(CrmConfig.AssignmentConfig), "'{}'::jsonb")] [InlineData(typeof(CrmWebhookQueueItem), nameof(CrmWebhookQueueItem.Payload), "'{}'::jsonb")] [InlineData(typeof(CrmWebhookLog), nameof(CrmWebhookLog.RequestPayload), "'{}'::jsonb")] [InlineData(typeof(TenantCommissionSetting), nameof(TenantCommissionSetting.Config), "'{}'::jsonb")] [InlineData(typeof(CommissionSettlement), nameof(CommissionSettlement.Metadata), "'{}'::jsonb")] [InlineData(typeof(CommissionSettlementItem), nameof(CommissionSettlementItem.Metadata), "'{}'::jsonb")] public void Growth_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 Growth_tables_have_expected_unique_indexes_and_decimal_precision() { using var context = new TikuDbContext(Options); AssertHasUniqueIndex(nameof(ReferralCode.TenantId), nameof(ReferralCode.Code)); AssertHasUniqueIndex(nameof(ReferralLead.TenantId), nameof(ReferralLead.StudentUserId)); AssertHasUniqueIndex(nameof(CrmConfig.TenantId)); AssertHasUniqueIndex(nameof(CrmWebhookQueueItem.TenantId), nameof(CrmWebhookQueueItem.IdempotencyKey)); AssertHasUniqueIndex( nameof(CommissionSettlement.TenantId), nameof(CommissionSettlement.SettlementNo)); AssertHasUniqueIndex( nameof(CommissionSettlementItem.TenantId), nameof(CommissionSettlementItem.SourceType), nameof(CommissionSettlementItem.SourceId)); AssertPrecision(nameof(TenantCommissionSetting.DefaultRate), 6, 4); AssertPrecision(nameof(CommissionSettlement.DefaultRate), 6, 4); AssertPrecision(nameof(CommissionSettlementItem.CommissionRate), 6, 4); 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)); } void AssertPrecision(string propertyName, int precision, int scale) { var property = context.Model.FindEntityType(typeof(TEntity))! .FindProperty(propertyName)!; Assert.Equal(precision, property.GetPrecision()); Assert.Equal(scale, property.GetScale()); } } [Theory] [InlineData(typeof(AuditLog), nameof(AuditLog.Details), "'{}'::jsonb")] [InlineData(typeof(UserNotification), nameof(UserNotification.Metadata), "'{}'::jsonb")] [InlineData(typeof(Badge), nameof(Badge.ConditionExtra), "'{}'::jsonb")] [InlineData(typeof(TenantContentNotification), nameof(TenantContentNotification.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantThemeTemplate), nameof(TenantThemeTemplate.Theme), "'{}'::jsonb")] [InlineData(typeof(TenantThemeTemplate), nameof(TenantThemeTemplate.PublicAssets), "'{}'::jsonb")] [InlineData(typeof(TenantThemeConfig), nameof(TenantThemeConfig.ActiveTheme), "'{}'::jsonb")] [InlineData(typeof(TenantThemeConfig), nameof(TenantThemeConfig.ActivePublicAssets), "'{}'::jsonb")] [InlineData(typeof(TenantThemeConfig), nameof(TenantThemeConfig.DraftTheme), "'{}'::jsonb")] [InlineData(typeof(TenantThemeConfig), nameof(TenantThemeConfig.DraftPublicAssets), "'{}'::jsonb")] public void 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 Operations_tables_have_expected_unique_indexes_and_condition_precision() { using var context = new TikuDbContext(Options); AssertHasUniqueIndex(nameof(Banner.TenantId), nameof(Banner.LegacyId)); AssertHasUniqueIndex( nameof(UserNotification.TenantId), nameof(UserNotification.UserId), nameof(UserNotification.NotificationType), nameof(UserNotification.DedupeKey)); AssertHasUniqueIndex(nameof(Badge.TenantId), nameof(Badge.LegacyId)); AssertHasUniqueIndex( nameof(UserBadge.TenantId), nameof(UserBadge.UserId), nameof(UserBadge.BadgeId)); AssertHasUniqueIndex(nameof(TenantThemeTemplate.Code)); AssertHasUniqueIndex(nameof(TenantThemeConfig.TenantId)); var conditionValue = context.Model.FindEntityType(typeof(Badge))! .FindProperty(nameof(Badge.ConditionValue))!; Assert.Equal(18, conditionValue.GetPrecision()); Assert.Equal(4, conditionValue.GetScale()); 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(PlatformSaasPlan), nameof(PlatformSaasPlan.IncludedQuotas), "'{}'::jsonb")] [InlineData(typeof(PlatformSaasPlan), nameof(PlatformSaasPlan.OveragePrices), "'{}'::jsonb")] [InlineData(typeof(PlatformSaasPlan), nameof(PlatformSaasPlan.FeatureFlags), "'{}'::jsonb")] [InlineData(typeof(TenantBillingProfile), nameof(TenantBillingProfile.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantInvoice), nameof(TenantInvoice.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantInvoiceItem), nameof(TenantInvoiceItem.Metadata), "'{}'::jsonb")] [InlineData(typeof(TenantInvoicePayment), nameof(TenantInvoicePayment.RawPayload), "'{}'::jsonb")] [InlineData(typeof(TenantInvoiceReminder), nameof(TenantInvoiceReminder.Metadata), "'{}'::jsonb")] [InlineData(typeof(PlatformAuditAlertRule), nameof(PlatformAuditAlertRule.Conditions), "'{}'::jsonb")] [InlineData(typeof(PlatformAuditAlertRule), nameof(PlatformAuditAlertRule.Metadata), "'{}'::jsonb")] [InlineData(typeof(PlatformAuditAlert), nameof(PlatformAuditAlert.Details), "'{}'::jsonb")] [InlineData(typeof(PlatformDunningNotificationChannel), nameof(PlatformDunningNotificationChannel.Metadata), "'{}'::jsonb")] [InlineData(typeof(PlatformDunningNotificationEvent), nameof(PlatformDunningNotificationEvent.RequestPayload), "'{}'::jsonb")] [InlineData(typeof(PlatformDunningNotificationEvent), nameof(PlatformDunningNotificationEvent.Metadata), "'{}'::jsonb")] public void Platform_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 Platform_operations_have_expected_unique_indexes_arrays_and_money_fields() { using var context = new TikuDbContext(Options); AssertHasUniqueIndex(nameof(PlatformSaasPlan.Code)); AssertHasUniqueIndex(nameof(TenantInvoice.InvoiceNo)); AssertHasUniqueIndex( nameof(TenantInvoice.TenantId), nameof(TenantInvoice.BillingPeriodStart), nameof(TenantInvoice.BillingPeriodEnd)); AssertHasUniqueIndex( nameof(TenantInvoiceReminder.TenantId), nameof(TenantInvoiceReminder.InvoiceId), nameof(TenantInvoiceReminder.ReminderType), nameof(TenantInvoiceReminder.Channel), nameof(TenantInvoiceReminder.ReminderDate)); AssertHasUniqueIndex(nameof(PlatformAuditAlertRule.Code)); AssertHasUniqueIndex(nameof(PlatformAuditAlert.RuleId), nameof(PlatformAuditAlert.AuditLogId)); AssertHasUniqueIndex(nameof(PlatformDunningNotificationChannel.ChannelCode)); AssertHasUniqueIndex( nameof(PlatformDunningNotificationEvent.ChannelId), nameof(PlatformDunningNotificationEvent.ReminderId)); Assert.Equal( "text[]", context.Model.FindEntityType(typeof(PlatformAuditAlertRule))! .FindProperty(nameof(PlatformAuditAlertRule.ActionPatterns))! .GetColumnType()); Assert.Equal( "uuid[]", context.Model.FindEntityType(typeof(PlatformDunningNotificationChannel))! .FindProperty(nameof(PlatformDunningNotificationChannel.TenantIds))! .GetColumnType()); var total = context.Model.FindEntityType(typeof(TenantInvoice))! .FindProperty(nameof(TenantInvoice.TotalCents))!; Assert.Equal(typeof(int), total.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)); } } [Theory] [InlineData(typeof(PocketBaseImportRun), nameof(PocketBaseImportRun.Stats), "'{}'::jsonb")] [InlineData(typeof(PocketBaseRawRecord), nameof(PocketBaseRawRecord.Record), "'{}'::jsonb")] [InlineData(typeof(PocketBaseRawRecord), nameof(PocketBaseRawRecord.Errors), "'[]'::jsonb")] public void PocketBase_import_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 PocketBase_import_raw_records_keep_legacy_identity_in_primary_key() { using var context = new TikuDbContext(Options); var keyProperties = context.Model.FindEntityType(typeof(PocketBaseRawRecord))! .FindPrimaryKey()! .Properties .Select(property => property.Name) .ToArray(); Assert.Equal( [ nameof(PocketBaseRawRecord.RunId), nameof(PocketBaseRawRecord.CollectionName), nameof(PocketBaseRawRecord.LegacyId) ], keyProperties); } }