diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 07eaca9..c5206da 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -17,7 +17,8 @@ "Bash(sudo rm:*)", "Bash(rm:*)", "Bash(curl:*)", - "Bash(docker-compose up:*)" + "Bash(docker-compose up:*)", + "Bash(dotnet build:*)" ] }, "enableAllProjectMcpServers": false diff --git a/BCards.sln b/BCards.sln index 9b1a83d..70adb9c 100644 --- a/BCards.sln +++ b/BCards.sln @@ -4,7 +4,7 @@ VisualStudioVersion = 17.0.31903.59 MinimumVisualStudioVersion = 10.0.40219.1 Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Tests", "tests\BCards.Tests\BCards.Tests.csproj", "{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}" +Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.IntegrationTests", "src\BCards.IntegrationTests\BCards.IntegrationTests.csproj", "{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -16,10 +16,10 @@ Global {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU - {5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.Build.0 = Debug|Any CPU - {5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.ActiveCfg = Release|Any CPU - {5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.Build.0 = Release|Any CPU + {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj b/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj new file mode 100644 index 0000000..05d8f00 --- /dev/null +++ b/src/BCards.IntegrationTests/BCards.IntegrationTests.csproj @@ -0,0 +1,45 @@ + + + + net8.0 + enable + enable + false + true + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + + + + + + + + + + + Always + + + + \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Fixtures/BCardsWebApplicationFactory.cs b/src/BCards.IntegrationTests/Fixtures/BCardsWebApplicationFactory.cs new file mode 100644 index 0000000..de22b33 --- /dev/null +++ b/src/BCards.IntegrationTests/Fixtures/BCardsWebApplicationFactory.cs @@ -0,0 +1,133 @@ +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using MongoDB.Driver; +using BCards.Web.Configuration; +using BCards.Web.Services; +using Testcontainers.MongoDb; +using Xunit; + +namespace BCards.IntegrationTests.Fixtures; + +public class BCardsWebApplicationFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly MongoDbContainer _mongoContainer = new MongoDbBuilder() + .WithImage("mongo:7.0") + .WithEnvironment("MONGO_INITDB_DATABASE", "BCardsDB_Test") + .Build(); + + public IMongoDatabase TestDatabase { get; private set; } = null!; + public string TestDatabaseName => $"BCardsDB_Test_{Guid.NewGuid():N}"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureAppConfiguration((context, config) => + { + // Remove existing configuration and add test configuration + config.Sources.Clear(); + config.AddJsonFile("appsettings.Testing.json", optional: false, reloadOnChange: false); + config.AddInMemoryCollection(new Dictionary + { + ["MongoDb:ConnectionString"] = _mongoContainer.GetConnectionString(), + ["MongoDb:DatabaseName"] = TestDatabaseName, + ["ASPNETCORE_ENVIRONMENT"] = "Testing" + }); + }); + + builder.ConfigureServices(services => + { + // Remove existing MongoDB services + services.RemoveAll(typeof(IMongoClient)); + services.RemoveAll(typeof(IMongoDatabase)); + + // Add test MongoDB services + services.AddSingleton(serviceProvider => + { + return new MongoClient(_mongoContainer.GetConnectionString()); + }); + + services.AddScoped(serviceProvider => + { + var client = serviceProvider.GetRequiredService(); + TestDatabase = client.GetDatabase(TestDatabaseName); + return TestDatabase; + }); + + // Override Stripe settings for testing + services.Configure(options => + { + options.PublishableKey = "pk_test_51234567890abcdef"; + options.SecretKey = "sk_test_51234567890abcdef"; + options.WebhookSecret = "whsec_test_1234567890abcdef"; + }); + + // Mock external services that we don't want to test + services.RemoveAll(typeof(IEmailService)); + services.AddScoped(); + }); + + builder.UseEnvironment("Testing"); + + // Reduce logging noise during tests + builder.ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + logging.SetMinimumLevel(LogLevel.Warning); + logging.AddFilter("BCards", LogLevel.Information); + }); + } + + public async Task InitializeAsync() + { + await _mongoContainer.StartAsync(); + } + + public new async Task DisposeAsync() + { + await _mongoContainer.DisposeAsync(); + await base.DisposeAsync(); + } + + public async Task CleanDatabaseAsync() + { + if (TestDatabase != null) + { + var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" }; + + foreach (var collectionName in collections) + { + try + { + await TestDatabase.DropCollectionAsync(collectionName); + } + catch (Exception) + { + // Ignore errors if collection doesn't exist + } + } + } + } +} + +// Mock email service to avoid external dependencies in tests +public class MockEmailService : IEmailService +{ + public Task SendModerationStatusAsync(string userEmail, string userName, string pageTitle, string status, string? reason = null, string? previewUrl = null) + { + return Task.CompletedTask; + } + + public Task SendModeratorNotificationAsync(string pageId, string pageTitle, string planType, string userName) + { + return Task.CompletedTask; + } + + public Task SendEmailAsync(string to, string subject, string htmlContent) + { + return Task.FromResult(true); + } +} \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs b/src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs new file mode 100644 index 0000000..034c568 --- /dev/null +++ b/src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs @@ -0,0 +1,182 @@ +using MongoDB.Driver; +using BCards.Web.Models; +using BCards.Web.Repositories; +using BCards.Web.ViewModels; + +namespace BCards.IntegrationTests.Fixtures; + +public class MongoDbTestFixture +{ + public IMongoDatabase Database { get; } + public IUserRepository UserRepository { get; } + public IUserPageRepository UserPageRepository { get; } + public ICategoryRepository CategoryRepository { get; } + + public MongoDbTestFixture(IMongoDatabase database) + { + Database = database; + UserRepository = new UserRepository(database); + UserPageRepository = new UserPageRepository(database); + CategoryRepository = new CategoryRepository(database); + } + + public async Task InitializeTestDataAsync() + { + // Initialize test categories + var categories = new List + { + new() { Id = "tecnologia", Name = "Tecnologia", Description = "Empresas e profissionais de tecnologia" }, + new() { Id = "negocios", Name = "Negócios", Description = "Empresas e empreendedores" }, + new() { Id = "pessoal", Name = "Pessoal", Description = "Páginas pessoais e freelancers" }, + new() { Id = "saude", Name = "Saúde", Description = "Profissionais da área da saúde" } + }; + + var existingCategories = await CategoryRepository.GetAllActiveAsync(); + if (!existingCategories.Any()) + { + foreach (var category in categories) + { + await CategoryRepository.CreateAsync(category); + } + } + } + + public async Task CreateTestUserAsync(PlanType planType = PlanType.Basic, string? email = null, string? name = null) + { + var user = new User + { + Id = Guid.NewGuid().ToString(), + Email = email ?? $"test-{Guid.NewGuid():N}@example.com", + Name = name ?? "Test User", + CurrentPlan = planType.ToString(), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + IsActive = true + }; + + await UserRepository.CreateAsync(user); + return user; + } + + public async Task CreateTestUserPageAsync( + string userId, + PageStatus status = PageStatus.Creating, + string category = "tecnologia", + int normalLinkCount = 3, + int productLinkCount = 1, + string? slug = null) + { + var pageSlug = slug ?? $"test-page-{Guid.NewGuid():N}"; + + var userPage = new UserPage + { + Id = Guid.NewGuid().ToString(), + UserId = userId, + DisplayName = "Test Page", + Category = category, + Slug = pageSlug, + Bio = "Test page for integration testing", + Status = status, + BusinessType = "individual", + Theme = new PageTheme { Name = "minimalist" }, + Links = new List(), + CreatedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow, + ModerationAttempts = 0, + ModerationHistory = new List() + }; + + // Generate preview token for non-Active pages + if (status != PageStatus.Active) + { + userPage.PreviewToken = Guid.NewGuid().ToString("N")[..16]; + userPage.PreviewTokenExpiry = DateTime.UtcNow.AddHours(4); + } + + // Add normal links + for (int i = 0; i < normalLinkCount; i++) + { + userPage.Links.Add(new LinkItem + { + Title = $"Test Link {i + 1}", + Url = $"https://example.com/link{i + 1}", + Description = $"Description for test link {i + 1}", + Icon = "fas fa-link", + IsActive = true, + Order = i, + Type = LinkType.Normal + }); + } + + // Add product links + for (int i = 0; i < productLinkCount; i++) + { + userPage.Links.Add(new LinkItem + { + Title = $"Test Product {i + 1}", + Url = $"https://example.com/product{i + 1}", + Description = $"Description for test product {i + 1}", + Icon = "fas fa-shopping-cart", + IsActive = true, + Order = normalLinkCount + i, + Type = LinkType.Product, + ProductTitle = $"Amazing Product {i + 1}", + ProductPrice = "R$ 99,90", + ProductDescription = $"This is an amazing product for testing purposes {i + 1}", + ProductImage = $"https://example.com/images/product{i + 1}.jpg" + }); + } + + await UserPageRepository.CreateAsync(userPage); + return userPage; + } + + public async Task CreateTestUserWithPageAsync( + PlanType planType = PlanType.Basic, + PageStatus pageStatus = PageStatus.Creating, + int normalLinks = 3, + int productLinks = 1) + { + var user = await CreateTestUserAsync(planType); + var page = await CreateTestUserPageAsync(user.Id, pageStatus, "tecnologia", normalLinks, productLinks); + + return user; + } + + public async Task CleanAllDataAsync() + { + var collections = new[] { "users", "userpages", "categories", "livepages", "subscriptions" }; + + foreach (var collectionName in collections) + { + try + { + await Database.DropCollectionAsync(collectionName); + } + catch (Exception) + { + // Ignore errors if collection doesn't exist + } + } + + await InitializeTestDataAsync(); + } + + public async Task> GetUserPagesAsync(string userId) + { + var filter = Builders.Filter.Eq(p => p.UserId, userId); + var pages = await UserPageRepository.GetManyAsync(filter); + return pages.ToList(); + } + + public async Task GetUserPageAsync(string category, string slug) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(p => p.Category, category), + Builders.Filter.Eq(p => p.Slug, slug) + ); + + var pages = await UserPageRepository.GetManyAsync(filter); + return pages.FirstOrDefault(); + } +} \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs b/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs new file mode 100644 index 0000000..af5e473 --- /dev/null +++ b/src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs @@ -0,0 +1,92 @@ +using System.Security.Claims; +using System.Text.Encodings.Web; +using Microsoft.AspNetCore.Authentication; +using Microsoft.AspNetCore.Authentication.Cookies; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using BCards.Web.Models; + +namespace BCards.IntegrationTests.Helpers; + +public static class AuthenticationHelper +{ + public static async Task CreateAuthenticatedClientAsync( + WebApplicationFactory factory, + User testUser) + { + var client = factory.WithWebHostBuilder(builder => + { + builder.ConfigureServices(services => + { + services.AddAuthentication("Test") + .AddScheme( + "Test", options => { }); + }); + }).CreateClient(new WebApplicationFactoryClientOptions + { + AllowAutoRedirect = false + }); + + // Set the test user in headers for the TestAuthenticationHandler + client.DefaultRequestHeaders.Add("TestUserId", testUser.Id); + client.DefaultRequestHeaders.Add("TestUserEmail", testUser.Email); + client.DefaultRequestHeaders.Add("TestUserName", testUser.Name); + + return client; + } + + public static ClaimsPrincipal CreateTestClaimsPrincipal(User user) + { + var claims = new List + { + new(ClaimTypes.NameIdentifier, user.Id), + new(ClaimTypes.Email, user.Email), + new(ClaimTypes.Name, user.Name), + new("sub", user.Id), + new("email", user.Email), + new("name", user.Name) + }; + + var identity = new ClaimsIdentity(claims, "Test"); + return new ClaimsPrincipal(identity); + } +} + +public class TestAuthenticationHandler : AuthenticationHandler +{ + public TestAuthenticationHandler(IOptionsMonitor options, + ILoggerFactory logger, UrlEncoder encoder) + : base(options, logger, encoder) + { + } + + protected override Task HandleAuthenticateAsync() + { + var userId = Context.Request.Headers["TestUserId"].FirstOrDefault(); + var userEmail = Context.Request.Headers["TestUserEmail"].FirstOrDefault(); + var userName = Context.Request.Headers["TestUserName"].FirstOrDefault(); + + if (string.IsNullOrEmpty(userId) || string.IsNullOrEmpty(userEmail)) + { + return Task.FromResult(AuthenticateResult.Fail("No test user provided")); + } + + var claims = new List + { + new(ClaimTypes.NameIdentifier, userId), + new(ClaimTypes.Email, userEmail), + new(ClaimTypes.Name, userName ?? "Test User"), + new("sub", userId), + new("email", userEmail), + new("name", userName ?? "Test User") + }; + + var identity = new ClaimsIdentity(claims, "Test"); + var principal = new ClaimsPrincipal(identity); + var ticket = new AuthenticationTicket(principal, "Test"); + + return Task.FromResult(AuthenticateResult.Success(ticket)); + } +} \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs b/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs new file mode 100644 index 0000000..62e6c98 --- /dev/null +++ b/src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs @@ -0,0 +1,195 @@ +using PuppeteerSharp; +using Microsoft.AspNetCore.Mvc.Testing; + +namespace BCards.IntegrationTests.Helpers; + +public class PuppeteerTestHelper : IAsyncDisposable +{ + private IBrowser? _browser; + private IPage? _page; + private readonly string _baseUrl; + + public PuppeteerTestHelper(WebApplicationFactory factory) + { + _baseUrl = factory.Server.BaseAddress?.ToString() ?? "https://localhost:49178"; + } + + public async Task InitializeAsync() + { + // Download Chrome if not available + await new BrowserFetcher().DownloadAsync(); + + _browser = await Puppeteer.LaunchAsync(new LaunchOptions + { + Headless = true, // Set to false for debugging + Args = new[] + { + "--no-sandbox", + "--disable-setuid-sandbox", + "--disable-dev-shm-usage", + "--disable-web-security", + "--allow-running-insecure-content", + "--ignore-certificate-errors" + } + }); + + _page = await _browser.NewPageAsync(); + + // Set viewport for consistent testing + await _page.SetViewportAsync(new ViewPortOptions + { + Width = 1920, + Height = 1080 + }); + } + + public IPage Page => _page ?? throw new InvalidOperationException("PuppeteerTestHelper not initialized. Call InitializeAsync first."); + + public async Task NavigateToAsync(string relativeUrl) + { + var fullUrl = new Uri(new Uri(_baseUrl), relativeUrl).ToString(); + await Page.GoToAsync(fullUrl, new NavigationOptions + { + WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } + }); + } + + public async Task GetPageContentAsync() + { + return await Page.GetContentAsync(); + } + + public async Task GetPageTitleAsync() + { + return await Page.GetTitleAsync(); + } + + public async Task ElementExistsAsync(string selector) + { + try + { + await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions + { + Timeout = 5000 + }); + return true; + } + catch (WaitTaskTimeoutException) + { + return false; + } + } + + public async Task ClickAsync(string selector) + { + await Page.WaitForSelectorAsync(selector); + await Page.ClickAsync(selector); + } + + public async Task TypeAsync(string selector, string text) + { + await Page.WaitForSelectorAsync(selector); + await Page.TypeAsync(selector, text); + } + + public async Task FillFormAsync(Dictionary formData) + { + foreach (var kvp in formData) + { + await Page.WaitForSelectorAsync(kvp.Key); + await Page.EvaluateExpressionAsync($"document.querySelector('{kvp.Key}').value = ''"); + await Page.TypeAsync(kvp.Key, kvp.Value); + } + } + + public async Task SubmitFormAsync(string formSelector) + { + await Page.ClickAsync($"{formSelector} button[type='submit'], {formSelector} input[type='submit']"); + } + + public async Task WaitForNavigationAsync() + { + await Page.WaitForNavigationAsync(new NavigationOptions + { + WaitUntil = new[] { WaitUntilNavigation.Networkidle0 } + }); + } + + public async Task WaitForElementAsync(string selector, int timeoutMs = 10000) + { + await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions + { + Timeout = timeoutMs + }); + } + + public async Task GetElementTextAsync(string selector) + { + await Page.WaitForSelectorAsync(selector); + var element = await Page.QuerySelectorAsync(selector); + var text = await Page.EvaluateFunctionAsync("el => el.textContent", element); + return text?.Trim() ?? string.Empty; + } + + public async Task GetElementValueAsync(string selector) + { + await Page.WaitForSelectorAsync(selector); + var element = await Page.QuerySelectorAsync(selector); + var value = await Page.EvaluateFunctionAsync("el => el.value", element); + return value ?? string.Empty; + } + + public async Task IsElementVisibleAsync(string selector) + { + try + { + await Page.WaitForSelectorAsync(selector, new WaitForSelectorOptions + { + Visible = true, + Timeout = 2000 + }); + return true; + } + catch (WaitTaskTimeoutException) + { + return false; + } + } + + public async Task TakeScreenshotAsync(string fileName) + { + await Page.ScreenshotAsync(fileName); + } + + public async Task GetCurrentUrlAsync() + { + return Page.Url; + } + + public async Task> GetAllElementTextsAsync(string selector) + { + var elements = await Page.QuerySelectorAllAsync(selector); + var texts = new List(); + + foreach (var element in elements) + { + var text = await Page.EvaluateFunctionAsync("el => el.textContent", element); + texts.Add(text?.Trim() ?? string.Empty); + } + + return texts; + } + + public async ValueTask DisposeAsync() + { + if (_page != null) + { + await _page.CloseAsync(); + } + + if (_browser != null) + { + await _browser.CloseAsync(); + } + } +} \ No newline at end of file diff --git a/src/BCards.IntegrationTests/README.md b/src/BCards.IntegrationTests/README.md new file mode 100644 index 0000000..004947e --- /dev/null +++ b/src/BCards.IntegrationTests/README.md @@ -0,0 +1,157 @@ +# BCards Integration Tests + +Este projeto contém testes integrados para o sistema BCards, validando workflows completos desde a criação de páginas até o sistema de moderação. + +## Estrutura dos Testes + +### Fixtures +- **BCardsWebApplicationFactory**: Factory personalizada que configura ambiente de teste com MongoDB container +- **MongoDbTestFixture**: Helper para criar e gerenciar dados de teste no MongoDB +- **StripeTestFixture**: Mock para integração Stripe (futuro) + +### Helpers +- **AuthenticationHelper**: Mock para autenticação OAuth (Google/Microsoft) +- **PuppeteerTestHelper**: Automação de browser para testes E2E +- **TestDataBuilder**: Builders para criar objetos de teste (futuro) + +### Tests +- **PageCreationTests**: Validação de criação de páginas e limites por plano +- **PreviewTokenTests**: Sistema de preview tokens para páginas não-ativas +- **ModerationWorkflowTests**: Workflow completo de moderação +- **PlanLimitationTests**: Validação de limitações por plano (futuro) +- **StripeIntegrationTests**: Testes de upgrade via Stripe (futuro) + +## Cenários Testados + +### Sistema de Páginas +1. **Criação de páginas** respeitando limites dos planos (Trial: 1, Basic: 3, etc.) +2. **Status de páginas**: Creating → PendingModeration → Active/Rejected +3. **Preview tokens**: Acesso a páginas em desenvolvimento (4h de validade) +4. **Validação de limites**: Links normais vs produto por plano + +### Workflow de Moderação +1. **Submissão para moderação**: Creating → PendingModeration +2. **Aprovação**: PendingModeration → Active (page vira pública) +3. **Rejeição**: PendingModeration → Inactive/Rejected +4. **Preview system**: Acesso via token para pages não-Active + +### Plan Limitations (Basic vs Professional) +- **Basic**: 5 links máximo +- **Professional**: 15 links máximo +- **Trial**: 1 página, 3 links + 1 produto + +## Tecnologias Utilizadas + +- **xUnit**: Framework de testes +- **FluentAssertions**: Assertions expressivas +- **WebApplicationFactory**: Testes integrados ASP.NET Core +- **Testcontainers**: MongoDB container para isolamento +- **PuppeteerSharp**: Automação de browser (Chrome) +- **MongoDB.Driver**: Acesso direto ao banco para validações + +## Configuração + +### Pré-requisitos +- .NET 8 SDK +- Docker (para MongoDB container) +- Chrome/Chromium (baixado automaticamente pelo PuppeteerSharp) + +### Executar Testes +```bash +# Todos os testes +dotnet test src/BCards.IntegrationTests/ + +# Testes específicos +dotnet test src/BCards.IntegrationTests/ --filter "PageCreationTests" +dotnet test src/BCards.IntegrationTests/ --filter "PreviewTokenTests" +``` + +### Configuração Manual (MongoDB local) +Se preferir usar MongoDB local em vez do container: + +```json +// appsettings.Testing.json +{ + "MongoDb": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "BCardsDB_Test" + } +} +``` + +## Estrutura de Dados de Teste + +### User +- **Trial**: 1 página máx, links limitados +- **Basic**: 3 páginas, 5 links por página +- **Professional**: 5 páginas, 15 links por página + +### UserPage +- **Status**: Creating, PendingModeration, Active, Rejected +- **Preview Tokens**: 4h de validade para access não-Active +- **Links**: Normal vs Product (limites diferentes por plano) + +### Categories +- **tecnologia**: Empresas de tech +- **negocios**: Empresas e empreendedores +- **pessoal**: Freelancers e páginas pessoais +- **saude**: Profissionais da área da saúde + +## Padrões de Teste + +### Arrange-Act-Assert +Todos os testes seguem o padrão AAA: +```csharp +// Arrange +var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); +var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating); + +// Act +var response = await client.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null); + +// Assert +response.IsSuccessStatusCode.Should().BeTrue(); +``` + +### Cleanup Automático +- Cada teste usa database isolada (GUID no nome) +- Container MongoDB é destruído após os testes +- Sem interferência entre testes + +### Mocks +- **EmailService**: Mockado para evitar envios reais +- **StripeService**: Mockado para evitar cobrança real +- **OAuth**: Mockado para evitar dependência externa + +## Debug e Troubleshooting + +### PuppeteerSharp +Para debug visual dos testes de browser: +```csharp +_browser = await Puppeteer.LaunchAsync(new LaunchOptions +{ + Headless = false, // Mostra o browser + SlowMo = 100 // Delay entre ações +}); +``` + +### MongoDB +Para inspecionar dados durante testes, conecte no container: +```bash +docker exec -it mongosh BCardsDB_Test +``` + +### Logs +Logs são configurados para mostrar apenas warnings/errors durante testes. +Para debug detalhado, altere em `BCardsWebApplicationFactory`: +```csharp +logging.SetMinimumLevel(LogLevel.Information); +``` + +## Próximos Passos + +1. **PlanLimitationTests**: Validar todas as limitações por plano +2. **StripeIntegrationTests**: Testar upgrades via webhook +3. **PerformanceTests**: Testar carga no sistema de moderação +4. **E2E Tests**: Testes completos com PuppeteerSharp +5. **TrialExpirationTests**: Validar exclusão automática após 7 dias \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs b/src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs new file mode 100644 index 0000000..44ecd07 --- /dev/null +++ b/src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs @@ -0,0 +1,204 @@ +using Xunit; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using BCards.Web.Models; +using BCards.Web.ViewModels; +using BCards.Web.Services; +using BCards.IntegrationTests.Fixtures; +using BCards.IntegrationTests.Helpers; +using System.Net; + +namespace BCards.IntegrationTests.Tests; + +public class ModerationWorkflowTests : IClassFixture, IAsyncLifetime +{ + private readonly BCardsWebApplicationFactory _factory; + private readonly HttpClient _client; + private MongoDbTestFixture _dbFixture = null!; + + public ModerationWorkflowTests(BCardsWebApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _factory.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + _dbFixture = new MongoDbTestFixture(database); + + await _factory.CleanDatabaseAsync(); + await _dbFixture.InitializeTestDataAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task SubmitPageForModeration_ShouldChangeStatusToPendingModeration() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act - Submit page for moderation + var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + + // Verify page status changed in database + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.Status.Should().Be(PageStatus.PendingModeration); + updatedPage.ModerationAttempts.Should().BeGreaterThan(0); + } + + [Fact] + public async Task SubmitPageForModeration_WithoutActiveLinks_ShouldFail() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 0, 0); // No links + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act + var response = await authenticatedClient.PostAsync($"/Admin/SubmitForModeration/{page.Id}", null); + + // Assert + response.IsSuccessStatusCode.Should().BeFalse(); + + // Verify page status didn't change + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.Status.Should().Be(PageStatus.Creating); + } + + [Fact] + public async Task ApprovePage_ShouldChangeStatusToActive() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var moderationService = scope.ServiceProvider.GetRequiredService(); + + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); + + // Act - Approve the page + await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Page looks good"); + + // Assert + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.Status.Should().Be(PageStatus.Active); + updatedPage.ApprovedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1)); + updatedPage.ModerationHistory.Should().HaveCount(1); + updatedPage.ModerationHistory.First().Status.Should().Be("approved"); + } + + [Fact] + public async Task RejectPage_ShouldChangeStatusToRejected() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var moderationService = scope.ServiceProvider.GetRequiredService(); + + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); + + // Act - Reject the page + await moderationService.RejectPageAsync(page.Id, "test-moderator-id", "Inappropriate content", new List { "spam", "offensive" }); + + // Assert + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.Status.Should().Be(PageStatus.Inactive); // First rejection goes to Inactive + updatedPage.ModerationHistory.Should().HaveCount(1); + + var rejectionHistory = updatedPage.ModerationHistory.First(); + rejectionHistory.Status.Should().Be("rejected"); + rejectionHistory.Reason.Should().Be("Inappropriate content"); + rejectionHistory.Issues.Should().Contain("spam"); + rejectionHistory.Issues.Should().Contain("offensive"); + } + + [Fact] + public async Task AccessApprovedPage_WithoutPreviewToken_ShouldSucceed() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var moderationService = scope.ServiceProvider.GetRequiredService(); + + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); + + // Approve the page + await moderationService.ApprovePageAsync(page.Id, "test-moderator-id", "Approved"); + + // Act - Access the page without preview token + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(page.DisplayName); + content.Should().NotContain("MODO PREVIEW"); // Should not show preview banner + } + + [Fact] + public async Task GetPendingModerationPages_ShouldReturnCorrectPages() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var moderationService = scope.ServiceProvider.GetRequiredService(); + + var user1 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user1@example.com"); + var user2 = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "user2@example.com"); + + // Create pages in different statuses + await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); + await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.PendingModeration, "negocios", 4, 2); + await _dbFixture.CreateTestUserPageAsync(user1.Id, PageStatus.Creating, "pessoal", 2, 0); // Should not appear + await _dbFixture.CreateTestUserPageAsync(user2.Id, PageStatus.Active, "saude", 5, 1); // Should not appear + + // Act + var pendingPages = await moderationService.GetPendingModerationAsync(); + + // Assert + pendingPages.Should().HaveCount(2); + pendingPages.Should().OnlyContain(p => p.Status == PageStatus.PendingModeration); + } + + [Fact] + public async Task ModerationStats_ShouldReturnCorrectCounts() + { + // Arrange + using var scope = _factory.Services.CreateScope(); + var moderationService = scope.ServiceProvider.GetRequiredService(); + + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + + // Create pages with different statuses + var pendingPage1 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "tecnologia", 3, 1); + var pendingPage2 = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "negocios", 3, 1); + var activePage = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.PendingModeration, "pessoal", 3, 1); + + // Approve one page today + await moderationService.ApprovePageAsync(activePage.Id, "moderator", "Good"); + + // Act + var stats = await moderationService.GetModerationStatsAsync(); + + // Assert + stats["pending"].Should().Be(2); + stats["approvedToday"].Should().Be(1); + stats["rejectedToday"].Should().Be(0); + } +} \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Tests/PageCreationTests.cs b/src/BCards.IntegrationTests/Tests/PageCreationTests.cs new file mode 100644 index 0000000..9e994a1 --- /dev/null +++ b/src/BCards.IntegrationTests/Tests/PageCreationTests.cs @@ -0,0 +1,238 @@ +using Xunit; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using BCards.Web.Models; +using BCards.Web.ViewModels; +using BCards.IntegrationTests.Fixtures; +using BCards.IntegrationTests.Helpers; +using System.Net.Http.Json; + +namespace BCards.IntegrationTests.Tests; + +public class PageCreationTests : IClassFixture, IAsyncLifetime +{ + private readonly BCardsWebApplicationFactory _factory; + private readonly HttpClient _client; + private MongoDbTestFixture _dbFixture = null!; + + public PageCreationTests(BCardsWebApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _factory.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + _dbFixture = new MongoDbTestFixture(database); + + await _factory.CleanDatabaseAsync(); + await _dbFixture.InitializeTestDataAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task CreatePage_WithBasicPlan_ShouldAllowUpTo5Links() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act - Create a page with 5 links (should succeed) + var pageData = new + { + DisplayName = "Test Business Page", + Category = "tecnologia", + BusinessType = "company", + Bio = "A test business page", + Slug = "test-business", + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "Website", Url = "https://example.com", Description = "Main website", Icon = "fas fa-globe" }, + new { Title = "Email", Url = "mailto:contact@example.com", Description = "Contact email", Icon = "fas fa-envelope" }, + new { Title = "Phone", Url = "tel:+5511999999999", Description = "Contact phone", Icon = "fas fa-phone" }, + new { Title = "LinkedIn", Url = "https://linkedin.com/company/example", Description = "LinkedIn profile", Icon = "fab fa-linkedin" }, + new { Title = "Instagram", Url = "https://instagram.com/example", Description = "Instagram profile", Icon = "fab fa-instagram" } + } + }; + + var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); + + // Assert + createResponse.IsSuccessStatusCode.Should().BeTrue("Basic plan should allow 5 links"); + + // Verify page was created in database + var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); + createdPages.Should().HaveCount(1); + + var createdPage = createdPages.First(); + createdPage.DisplayName.Should().Be("Test Business Page"); + createdPage.Category.Should().Be("tecnologia"); + createdPage.Status.Should().Be(PageStatus.Creating); + createdPage.Links.Should().HaveCount(5); + } + + [Fact] + public async Task CreatePage_WithBasicPlanExceedingLimits_ShouldFail() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act - Try to create a page with 6 links (should fail for Basic plan) + var pageData = new + { + DisplayName = "Test Page Exceeding Limits", + Category = "tecnologia", + BusinessType = "individual", + Bio = "A test page with too many links", + Slug = "test-exceeding", + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "Link 1", Url = "https://example1.com", Description = "Link 1", Icon = "fas fa-link" }, + new { Title = "Link 2", Url = "https://example2.com", Description = "Link 2", Icon = "fas fa-link" }, + new { Title = "Link 3", Url = "https://example3.com", Description = "Link 3", Icon = "fas fa-link" }, + new { Title = "Link 4", Url = "https://example4.com", Description = "Link 4", Icon = "fas fa-link" }, + new { Title = "Link 5", Url = "https://example5.com", Description = "Link 5", Icon = "fas fa-link" }, + new { Title = "Link 6", Url = "https://example6.com", Description = "Link 6", Icon = "fas fa-link" } // This should fail + } + }; + + var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); + + // Assert + createResponse.IsSuccessStatusCode.Should().BeFalse("Basic plan should not allow more than 5 links"); + + // Verify no page was created + var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); + createdPages.Should().BeEmpty(); + } + + [Fact] + public async Task CreatePage_ShouldStartInCreatingStatus() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act + var pageData = new + { + DisplayName = "New Page", + Category = "pessoal", + BusinessType = "individual", + Bio = "Test page bio", + Slug = "new-page", + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "Portfolio", Url = "https://myportfolio.com", Description = "My work", Icon = "fas fa-briefcase" } + } + }; + + var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); + + // Assert + createResponse.IsSuccessStatusCode.Should().BeTrue(); + + var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); + var page = createdPages.First(); + + page.Status.Should().Be(PageStatus.Creating); + page.PreviewToken.Should().NotBeNullOrEmpty("Creating pages should have preview tokens"); + page.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3), "Preview token should be valid for ~4 hours"); + } + + [Fact] + public async Task CreatePage_WithTrialPlan_ShouldAllowOnePageOnly() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Trial); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Act - Create first page (should succeed) + var firstPageData = new + { + DisplayName = "First Trial Page", + Category = "pessoal", + BusinessType = "individual", + Bio = "First page in trial", + Slug = "first-trial", + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "Website", Url = "https://example.com", Description = "My website", Icon = "fas fa-globe" } + } + }; + + var firstResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", firstPageData); + firstResponse.IsSuccessStatusCode.Should().BeTrue("Trial should allow first page"); + + // Act - Try to create second page (should fail) + var secondPageData = new + { + DisplayName = "Second Trial Page", + Category = "tecnologia", + BusinessType = "individual", + Bio = "Second page in trial - should fail", + Slug = "second-trial", + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "LinkedIn", Url = "https://linkedin.com/in/test", Description = "LinkedIn", Icon = "fab fa-linkedin" } + } + }; + + var secondResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", secondPageData); + + // Assert + secondResponse.IsSuccessStatusCode.Should().BeFalse("Trial should not allow second page"); + + var createdPages = await _dbFixture.GetUserPagesAsync(user.Id); + createdPages.Should().HaveCount(1, "Trial should only have one page"); + } + + [Fact] + public async Task CreatePage_ShouldGenerateUniqueSlug() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Professional); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Create first page with specific slug + await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1, "test-slug"); + + // Act - Try to create another page with same name (should get different slug) + var pageData = new + { + DisplayName = "Test Page", // Same display name, should generate different slug + Category = "tecnologia", + BusinessType = "individual", + Bio = "Another test page", + Slug = "test-slug", // Try to use same slug + SelectedTheme = "minimalist", + Links = new[] + { + new { Title = "Website", Url = "https://example.com", Description = "Website", Icon = "fas fa-globe" } + } + }; + + var createResponse = await authenticatedClient.PostAsJsonAsync("/Admin/ManagePage", pageData); + + // Assert + createResponse.IsSuccessStatusCode.Should().BeTrue(); + + var userPages = await _dbFixture.GetUserPagesAsync(user.Id); + userPages.Should().HaveCount(2); + + var slugs = userPages.Select(p => p.Slug).ToList(); + slugs.Should().OnlyHaveUniqueItems("All pages should have unique slugs"); + slugs.Should().Contain("test-slug"); + slugs.Should().Contain(slug => slug.StartsWith("test-slug-") || slug == "test-page"); + } +} \ No newline at end of file diff --git a/src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs b/src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs new file mode 100644 index 0000000..d045efe --- /dev/null +++ b/src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs @@ -0,0 +1,240 @@ +using Xunit; +using FluentAssertions; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; +using BCards.Web.Models; +using BCards.Web.ViewModels; +using BCards.IntegrationTests.Fixtures; +using BCards.IntegrationTests.Helpers; +using System.Net; + +namespace BCards.IntegrationTests.Tests; + +public class PreviewTokenTests : IClassFixture, IAsyncLifetime +{ + private readonly BCardsWebApplicationFactory _factory; + private readonly HttpClient _client; + private MongoDbTestFixture _dbFixture = null!; + + public PreviewTokenTests(BCardsWebApplicationFactory factory) + { + _factory = factory; + _client = _factory.CreateClient(); + } + + public async Task InitializeAsync() + { + using var scope = _factory.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + _dbFixture = new MongoDbTestFixture(database); + + await _factory.CleanDatabaseAsync(); + await _dbFixture.InitializeTestDataAsync(); + } + + public Task DisposeAsync() => Task.CompletedTask; + + [Fact] + public async Task AccessPageInCreatingStatus_WithValidPreviewToken_ShouldSucceed() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(page.DisplayName); + content.Should().Contain("MODO PREVIEW"); // Preview banner should be shown + } + + [Fact] + public async Task AccessPageInCreatingStatus_WithoutPreviewToken_ShouldReturn404() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("Página em desenvolvimento. Acesso restrito."); + } + + [Fact] + public async Task AccessPageInCreatingStatus_WithInvalidPreviewToken_ShouldReturn404() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview=invalid-token"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("Página em desenvolvimento. Acesso restrito."); + } + + [Fact] + public async Task AccessPageInCreatingStatus_WithExpiredPreviewToken_ShouldReturn404() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + + // Simulate expired token + page.PreviewTokenExpiry = DateTime.UtcNow.AddHours(-1); // Expired 1 hour ago + await _dbFixture.UserPageRepository.UpdateAsync(page); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain("Página em desenvolvimento. Acesso restrito."); + } + + [Fact] + public async Task GeneratePreviewToken_ForCreatingPage_ShouldReturnNewToken() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + var oldToken = page.PreviewToken; + + // Act + var response = await authenticatedClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + + var jsonResponse = await response.Content.ReadAsStringAsync(); + jsonResponse.Should().Contain("success"); + jsonResponse.Should().Contain("previewToken"); + + // Verify new token is different and works + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.PreviewToken.Should().NotBe(oldToken); + updatedPage.PreviewTokenExpiry.Should().BeAfter(DateTime.UtcNow.AddHours(3)); + + // Test new token works + var pageResponse = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={updatedPage.PreviewToken}"); + pageResponse.StatusCode.Should().Be(HttpStatusCode.OK); + } + + [Fact] + public async Task GeneratePreviewToken_ForActivePageByNonOwner_ShouldFail() + { + // Arrange + var pageOwner = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "owner@example.com"); + var otherUser = await _dbFixture.CreateTestUserAsync(PlanType.Basic, "other@example.com"); + var page = await _dbFixture.CreateTestUserPageAsync(pageOwner.Id, PageStatus.Active, "tecnologia", 3, 1); + + var otherUserClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, otherUser); + + // Act + var response = await otherUserClient.PostAsync($"/Admin/GeneratePreviewToken/{page.Id}", null); + + // Assert + response.IsSuccessStatusCode.Should().BeFalse(); + } + + [Theory] + [InlineData(PageStatus.Creating)] + [InlineData(PageStatus.PendingModeration)] + [InlineData(PageStatus.Rejected)] + public async Task AccessPage_WithPreviewToken_ShouldWorkForNonActiveStatuses(PageStatus status) + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}?preview={page.PreviewToken}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK, $"Preview token should work for {status} status"); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(page.DisplayName); + } + + [Theory] + [InlineData(PageStatus.Creating)] + [InlineData(PageStatus.PendingModeration)] + [InlineData(PageStatus.Rejected)] + public async Task AccessPage_WithoutPreviewToken_ShouldFailForNonActiveStatuses(PageStatus status) + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, status, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.NotFound, $"Access without preview token should fail for {status} status"); + } + + [Fact] + public async Task AccessActivePage_WithoutPreviewToken_ShouldSucceed() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Active, "tecnologia", 3, 1); + + // Act + var response = await _client.GetAsync($"/page/{page.Category}/{page.Slug}"); + + // Assert + response.StatusCode.Should().Be(HttpStatusCode.OK); + + var content = await response.Content.ReadAsStringAsync(); + content.Should().Contain(page.DisplayName); + content.Should().NotContain("MODO PREVIEW"); // No preview banner for active pages + } + + [Fact] + public async Task RefreshPreviewToken_ShouldExtendExpiry() + { + // Arrange + var user = await _dbFixture.CreateTestUserAsync(PlanType.Basic); + var page = await _dbFixture.CreateTestUserPageAsync(user.Id, PageStatus.Creating, "tecnologia", 3, 1); + var authenticatedClient = await AuthenticationHelper.CreateAuthenticatedClientAsync(_factory, user); + + // Make token close to expiry + page.PreviewTokenExpiry = DateTime.UtcNow.AddMinutes(30); + await _dbFixture.UserPageRepository.UpdateAsync(page); + + var oldExpiry = page.PreviewTokenExpiry; + + // Act + var response = await authenticatedClient.PostAsync($"/Admin/RefreshPreviewToken/{page.Id}", null); + + // Assert + response.IsSuccessStatusCode.Should().BeTrue(); + + var updatedPages = await _dbFixture.GetUserPagesAsync(user.Id); + var updatedPage = updatedPages.First(); + + updatedPage.PreviewTokenExpiry.Should().BeAfter(oldExpiry.Value.AddHours(3)); + updatedPage.PreviewToken.Should().NotBeNullOrEmpty(); + } +} \ No newline at end of file diff --git a/src/BCards.IntegrationTests/appsettings.Testing.json b/src/BCards.IntegrationTests/appsettings.Testing.json new file mode 100644 index 0000000..8d18e46 --- /dev/null +++ b/src/BCards.IntegrationTests/appsettings.Testing.json @@ -0,0 +1,43 @@ +{ + "ConnectionStrings": { + "DefaultConnection": "mongodb://localhost:27017/BCardsDB_Test" + }, + "MongoDb": { + "ConnectionString": "mongodb://localhost:27017", + "DatabaseName": "BCardsDB_Test" + }, + "Stripe": { + "PublishableKey": "pk_test_51234567890abcdef", + "SecretKey": "sk_test_51234567890abcdef", + "WebhookSecret": "whsec_test_1234567890abcdef" + }, + "Authentication": { + "Google": { + "ClientId": "test-google-client-id.apps.googleusercontent.com", + "ClientSecret": "GOCSPX-test-google-client-secret" + }, + "Microsoft": { + "ClientId": "test-microsoft-client-id", + "ClientSecret": "test-microsoft-client-secret" + } + }, + "SendGrid": { + "ApiKey": "SG.test-sendgrid-api-key" + }, + "Moderation": { + "RequireApproval": true, + "AuthKey": "test-moderation-auth-key", + "MaxPendingPages": 100, + "MaxRejectionsBeforeBan": 3 + }, + "Logging": { + "LogLevel": { + "Default": "Warning", + "Microsoft.AspNetCore": "Warning", + "BCards": "Information", + "Microsoft.EntityFrameworkCore": "Warning" + } + }, + "AllowedHosts": "*", + "ASPNETCORE_ENVIRONMENT": "Testing" +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/AdminController.cs b/src/BCards.Web/Controllers/AdminController.cs index 4662e7c..2786714 100644 --- a/src/BCards.Web/Controllers/AdminController.cs +++ b/src/BCards.Web/Controllers/AdminController.cs @@ -18,6 +18,7 @@ public class AdminController : Controller private readonly IThemeService _themeService; private readonly IModerationService _moderationService; private readonly IEmailService _emailService; + private readonly ILivePageService _livePageService; private readonly ILogger _logger; public AdminController( @@ -27,6 +28,7 @@ public class AdminController : Controller IThemeService themeService, IModerationService moderationService, IEmailService emailService, + ILivePageService livePageService, ILogger logger) { _authService = authService; @@ -35,6 +37,7 @@ public class AdminController : Controller _themeService = themeService; _moderationService = moderationService; _emailService = emailService; + _livePageService = livePageService; _logger = logger; } @@ -248,7 +251,7 @@ public class AdminController : Controller UpdateUserPageFromModel(existingPage, model); // Set status to PendingModeration for updates - existingPage.Status = ViewModels.PageStatus.PendingModeration; + existingPage.Status = ViewModels.PageStatus.Creating; existingPage.ModerationAttempts = existingPage.ModerationAttempts; await _userPageService.UpdatePageAsync(existingPage); @@ -493,10 +496,11 @@ public class AdminController : Controller public async Task GenerateSlug(string category, string name) { if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name)) - return Json(new { slug = "" }); + return Json(new { slug = "", category = "" }); var slug = await _userPageService.GenerateSlugAsync(category, name); - return Json(new { slug }); + var categorySlug = SlugHelper.CreateCategorySlug(category).ToLower(); + return Json(new { slug = slug, category = categorySlug }); } [HttpGet] @@ -884,4 +888,74 @@ public class AdminController : Controller return Json(new { success = false, message = "Erro interno. Tente novamente." }); } } + + [HttpPost] + [Route("MigrateToLivePages")] + public async Task MigrateToLivePages() + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return Json(new { success = false, message = "Usuário não autenticado" }); + + try + { + // Buscar todas as páginas ativas do usuário atual + var activePages = await _userPageService.GetUserPagesAsync(user.Id); + var eligiblePages = activePages.Where(p => p.Status == ViewModels.PageStatus.Active).ToList(); + + if (!eligiblePages.Any()) + { + return Json(new { + success = false, + message = "Nenhuma página ativa encontrada para migração" + }); + } + + int successCount = 0; + int errorCount = 0; + var errors = new List(); + + foreach (var page in eligiblePages) + { + try + { + await _livePageService.SyncFromUserPageAsync(page.Id); + successCount++; + _logger.LogInformation($"Successfully migrated page {page.Id} ({page.DisplayName}) to LivePages"); + } + catch (Exception ex) + { + errorCount++; + var errorMsg = $"Erro ao migrar '{page.DisplayName}': {ex.Message}"; + errors.Add(errorMsg); + _logger.LogError(ex, $"Failed to migrate page {page.Id} to LivePages"); + } + } + + var message = $"Migração concluída: {successCount} páginas migradas com sucesso"; + if (errorCount > 0) + { + message += $", {errorCount} erros encontrados"; + } + + return Json(new { + success = errorCount == 0, + message = message, + details = new { + totalPages = eligiblePages.Count, + successCount = successCount, + errorCount = errorCount, + errors = errors + } + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error during LivePages migration"); + return Json(new { + success = false, + message = $"Erro durante migração: {ex.Message}" + }); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/LivePageController.cs b/src/BCards.Web/Controllers/LivePageController.cs new file mode 100644 index 0000000..f835a3c --- /dev/null +++ b/src/BCards.Web/Controllers/LivePageController.cs @@ -0,0 +1,93 @@ +using BCards.Web.Services; +using Microsoft.AspNetCore.Mvc; + +namespace BCards.Web.Controllers; + +[Route("page")] +public class LivePageController : Controller +{ + private readonly ILivePageService _livePageService; + private readonly ILogger _logger; + + public LivePageController(ILivePageService livePageService, ILogger logger) + { + _livePageService = livePageService; + _logger = logger; + } + + [Route("{category}/{slug}")] + [ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })] + public async Task Display(string category, string slug) + { + // Se tem parâmetro preview, redirecionar para sistema de preview + if (HttpContext.Request.Query.ContainsKey("preview")) + { + _logger.LogInformation("Redirecting preview request for {Category}/{Slug} to UserPageController", category, slug); + return RedirectToAction("Display", "UserPage", new { + category = category, + slug = slug, + preview = HttpContext.Request.Query["preview"].ToString() + }); + } + + var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug); + if (livePage == null) + { + _logger.LogInformation("LivePage not found for {Category}/{Slug}, falling back to UserPageController", category, slug); + // Fallback: tentar no sistema antigo + return RedirectToAction("Display", "UserPage", new { category = category, slug = slug }); + } + + // Incrementar view de forma assíncrona (não bloquear response) + _ = Task.Run(async () => + { + try + { + await _livePageService.IncrementViewAsync(livePage.Id); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePage.Id); + } + }); + + // Configurar ViewBag para indicar que é uma live page + ViewBag.IsLivePage = true; + ViewBag.PageUrl = $"https://vcart.me/page/{category}/{slug}"; + ViewBag.Title = $"{livePage.DisplayName} - {livePage.Category} | BCards"; + + _logger.LogInformation("Serving LivePage {LivePageId} for {Category}/{Slug}", livePage.Id, category, slug); + + // Usar a mesma view do UserPage mas com dados da LivePage + return View("~/Views/UserPage/Display.cshtml", livePage); + } + + [Route("{category}/{slug}/link/{linkIndex}")] + public async Task TrackLinkClick(string category, string slug, int linkIndex) + { + var livePage = await _livePageService.GetByCategoryAndSlugAsync(category, slug); + if (livePage == null || linkIndex < 0 || linkIndex >= livePage.Links.Count) + { + return NotFound(); + } + + var link = livePage.Links[linkIndex]; + + // Track click de forma assíncrona + _ = Task.Run(async () => + { + try + { + await _livePageService.IncrementLinkClickAsync(livePage.Id, linkIndex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to track click for LivePage {LivePageId} link {LinkIndex}", livePage.Id, linkIndex); + } + }); + + _logger.LogInformation("Tracking click for LivePage {LivePageId} link {LinkIndex} -> {Url}", livePage.Id, linkIndex, link.Url); + + return Redirect(link.Url); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Controllers/ModerationController.cs b/src/BCards.Web/Controllers/ModerationController.cs index fe52959..3cac646 100644 --- a/src/BCards.Web/Controllers/ModerationController.cs +++ b/src/BCards.Web/Controllers/ModerationController.cs @@ -114,7 +114,7 @@ public class ModerationController : Controller user.Email, user.Name, page.DisplayName, - "approved"); + PageStatus.Active.ToString()); } TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!"; diff --git a/src/BCards.Web/Controllers/PaymentController.cs b/src/BCards.Web/Controllers/PaymentController.cs index 86b2066..b1b1355 100644 --- a/src/BCards.Web/Controllers/PaymentController.cs +++ b/src/BCards.Web/Controllers/PaymentController.cs @@ -1,5 +1,9 @@ +using BCards.Web.Models; +using BCards.Web.Repositories; using BCards.Web.Services; +using BCards.Web.ViewModels; using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; namespace BCards.Web.Controllers; @@ -9,11 +13,15 @@ public class PaymentController : Controller { private readonly IPaymentService _paymentService; private readonly IAuthService _authService; + private readonly IUserRepository _userService; + private readonly ISubscriptionRepository _subscriptionRepository; - public PaymentController(IPaymentService paymentService, IAuthService authService) + public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository) { _paymentService = paymentService; _authService = authService; + _userService = userService; + _subscriptionRepository = subscriptionRepository; } [HttpPost] @@ -26,6 +34,8 @@ public class PaymentController : Controller var successUrl = Url.Action("Success", "Payment", null, Request.Scheme); var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme); + TempData[$"PlanType|{user.Id}"] = planType; + try { var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync( @@ -43,10 +53,29 @@ public class PaymentController : Controller } } - public IActionResult Success() + public async Task Success() { - TempData["Success"] = "Assinatura ativada com sucesso! Agora você pode aproveitar todos os recursos do seu plano."; - return RedirectToAction("Dashboard", "Admin"); + var user = await _authService.GetCurrentUserAsync(User); + var planType = TempData[$"PlanType|{user.Id}"].ToString(); + + try + { + if (!string.IsNullOrEmpty(planType) && Enum.TryParse(planType, out var plan)) + { + user.CurrentPlan = plan.ToString(); + user.SubscriptionStatus = "active"; + await _userService.UpdateAsync(user); // ou o método equivalente + + TempData["Success"] = $"Assinatura {planType} ativada com sucesso!"; + } + + return RedirectToAction("Dashboard", "Admin"); + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao processar pagamento: {ex.Message}"; + return RedirectToAction("Dashboard", "Admin"); + } } public IActionResult Cancel() @@ -87,7 +116,36 @@ public class PaymentController : Controller if (user == null) return RedirectToAction("Login", "Auth"); - return View(user); + try + { + var viewModel = new ManageSubscriptionViewModel + { + User = user, + StripeSubscription = await _paymentService.GetSubscriptionDetailsAsync(user.Id), + PaymentHistory = await _paymentService.GetPaymentHistoryAsync(user.Id), + AvailablePlans = GetAvailablePlans(user.CurrentPlan) + }; + + // Pegar assinatura local se existir + if (!string.IsNullOrEmpty(user.StripeCustomerId)) + { + // Aqui você poderia buscar a subscription local se necessário + // viewModel.LocalSubscription = await _subscriptionRepository.GetByUserIdAsync(user.Id); + } + + return View(viewModel); + } + catch (Exception ex) + { + var errorViewModel = new ManageSubscriptionViewModel + { + User = user, + ErrorMessage = $"Erro ao carregar dados da assinatura: {ex.Message}", + AvailablePlans = GetAvailablePlans(user.CurrentPlan) + }; + + return View(errorViewModel); + } } [HttpPost] @@ -105,4 +163,111 @@ public class PaymentController : Controller return RedirectToAction("ManageSubscription"); } + + [HttpPost] + public async Task ChangePlan(string newPlanType) + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null) + return RedirectToAction("Login", "Auth"); + + try + { + // Para mudanças de plano, vamos usar o Stripe Checkout + var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme); + var cancelUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme); + + var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync( + user.Id, + newPlanType, + returnUrl!, + cancelUrl!); + + return Redirect(checkoutUrl); + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao alterar plano: {ex.Message}"; + return RedirectToAction("ManageSubscription"); + } + } + + [HttpPost] + public async Task OpenStripePortal() + { + var user = await _authService.GetCurrentUserAsync(User); + if (user == null || string.IsNullOrEmpty(user.StripeCustomerId)) + { + TempData["Error"] = "Erro: dados de assinatura não encontrados."; + return RedirectToAction("ManageSubscription"); + } + + try + { + var returnUrl = Url.Action("ManageSubscription", "Payment", null, Request.Scheme); + var portalUrl = await _paymentService.CreatePortalSessionAsync(user.StripeCustomerId, returnUrl!); + + return Redirect(portalUrl); + } + catch (Exception ex) + { + TempData["Error"] = $"Erro ao abrir portal de pagamento: {ex.Message}"; + return RedirectToAction("ManageSubscription"); + } + } + + private List GetAvailablePlans(string currentPlan) + { + var plans = new List + { + new() + { + PlanType = "basic", + DisplayName = "Básico", + Price = 9.90m, + PriceId = "price_basic", // Substitua pelos IDs reais do Stripe + MaxLinks = 5, + AllowAnalytics = true, + Features = new List { "5 links", "Temas básicos", "Análises básicas" }, + IsCurrentPlan = currentPlan == "basic" + }, + new() + { + PlanType = "professional", + DisplayName = "Profissional", + Price = 24.90m, + PriceId = "price_professional", // Substitua pelos IDs reais do Stripe + MaxLinks = 15, + AllowAnalytics = true, + AllowCustomDomain = true, + Features = new List { "15 links", "Todos os temas", "Domínio personalizado", "Análises avançadas" }, + IsCurrentPlan = currentPlan == "professional" + }, + new() + { + PlanType = "premium", + DisplayName = "Premium", + Price = 29.90m, + PriceId = "price_premium", // Substitua pelos IDs reais do Stripe + MaxLinks = -1, // Ilimitado + AllowCustomThemes = true, + AllowAnalytics = true, + AllowCustomDomain = true, + Features = new List { "Links ilimitados", "Temas personalizados", "Múltiplos domínios", "Suporte prioritário" }, + IsCurrentPlan = currentPlan == "premium" + } + }; + + // Marcar upgrades e downgrades + var currentPlanIndex = plans.FindIndex(p => p.IsCurrentPlan); + for (int i = 0; i < plans.Count; i++) + { + if (i > currentPlanIndex) + plans[i].IsUpgrade = true; + else if (i < currentPlanIndex) + plans[i].IsDowngrade = true; + } + + return plans; + } } \ No newline at end of file diff --git a/src/BCards.Web/Controllers/SitemapController.cs b/src/BCards.Web/Controllers/SitemapController.cs index 400ef62..f38d67e 100644 --- a/src/BCards.Web/Controllers/SitemapController.cs +++ b/src/BCards.Web/Controllers/SitemapController.cs @@ -8,11 +8,16 @@ namespace BCards.Web.Controllers; public class SitemapController : Controller { private readonly IUserPageService _userPageService; + private readonly ILivePageService _livePageService; private readonly ILogger _logger; - public SitemapController(IUserPageService userPageService, ILogger logger) + public SitemapController( + IUserPageService userPageService, + ILivePageService livePageService, + ILogger logger) { _userPageService = userPageService; + _livePageService = livePageService; _logger = logger; } @@ -22,7 +27,8 @@ public class SitemapController : Controller { try { - var activePages = await _userPageService.GetActivePagesAsync(); + // 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages + var livePages = await _livePageService.GetAllActiveAsync(); var sitemap = new XDocument( new XDeclaration("1.0", "utf-8", "yes"), @@ -43,11 +49,11 @@ public class SitemapController : Controller new XElement("priority", "0.9") ), - // Add user pages (only active ones) - activePages.Select(page => + // Add live pages (SEO-optimized URLs only) + livePages.Select(page => new XElement("url", new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"), - new XElement("lastmod", page.UpdatedAt.ToString("yyyy-MM-dd")), + new XElement("lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")), new XElement("changefreq", "weekly"), new XElement("priority", "0.8") ) @@ -55,7 +61,7 @@ public class SitemapController : Controller ) ); - _logger.LogInformation($"Generated sitemap with {activePages.Count} user pages"); + _logger.LogInformation($"Generated sitemap with {livePages.Count} live pages"); return Content(sitemap.ToString(), "application/xml", Encoding.UTF8); } diff --git a/src/BCards.Web/Controllers/UserPageController.cs b/src/BCards.Web/Controllers/UserPageController.cs index ac84d82..7f5688f 100644 --- a/src/BCards.Web/Controllers/UserPageController.cs +++ b/src/BCards.Web/Controllers/UserPageController.cs @@ -1,4 +1,6 @@ +using BCards.Web.Models; using BCards.Web.Services; +using BCards.Web.Utils; using Microsoft.AspNetCore.Mvc; namespace BCards.Web.Controllers; @@ -125,7 +127,8 @@ public class UserPageController : Controller ViewBag.Category = categoryObj; ViewBag.IsPreview = true; - + return View("Display", userPage); } + } \ No newline at end of file diff --git a/src/BCards.Web/Models/IPageDisplay.cs b/src/BCards.Web/Models/IPageDisplay.cs new file mode 100644 index 0000000..b85deb8 --- /dev/null +++ b/src/BCards.Web/Models/IPageDisplay.cs @@ -0,0 +1,26 @@ +namespace BCards.Web.Models +{ + /// + /// Interface comum para páginas que podem ser exibidas publicamente + /// Facilita o envio de dados para views sem duplicação de código + /// + public interface IPageDisplay + { + string Id { get; } + string UserId { get; } + string Category { get; } + string Slug { get; } + string DisplayName { get; } + string Bio { get; } + string ProfileImage { get; } + string BusinessType { get; } + PageTheme Theme { get; } + List Links { get; } + SeoSettings SeoSettings { get; } + string Language { get; } + DateTime CreatedAt { get; } + + // Propriedade calculada comum + string FullUrl { get; } + } +} diff --git a/src/BCards.Web/Models/LivePage.cs b/src/BCards.Web/Models/LivePage.cs new file mode 100644 index 0000000..a6c0eec --- /dev/null +++ b/src/BCards.Web/Models/LivePage.cs @@ -0,0 +1,75 @@ +using MongoDB.Bson; +using MongoDB.Bson.Serialization.Attributes; + +namespace BCards.Web.Models; + +public class LivePage : IPageDisplay +{ + [BsonId] + [BsonRepresentation(BsonType.ObjectId)] + public string Id { get; set; } = string.Empty; + + [BsonElement("originalPageId")] + [BsonRepresentation(BsonType.ObjectId)] + public string OriginalPageId { get; set; } = string.Empty; + + [BsonElement("userId")] + [BsonRepresentation(BsonType.ObjectId)] + public string UserId { get; set; } = string.Empty; + + [BsonElement("category")] + public string Category { get; set; } = string.Empty; + + [BsonElement("slug")] + public string Slug { get; set; } = string.Empty; + + [BsonElement("displayName")] + public string DisplayName { get; set; } = string.Empty; + + [BsonElement("bio")] + public string Bio { get; set; } = string.Empty; + + [BsonElement("profileImage")] + public string ProfileImage { get; set; } = string.Empty; + + [BsonElement("businessType")] + public string BusinessType { get; set; } = string.Empty; + + [BsonElement("theme")] + public PageTheme Theme { get; set; } = new(); + + [BsonElement("links")] + public List Links { get; set; } = new(); + + [BsonElement("seoSettings")] + public SeoSettings SeoSettings { get; set; } = new(); + + [BsonElement("language")] + public string Language { get; set; } = "pt-BR"; + + [BsonElement("analytics")] + public LivePageAnalytics Analytics { get; set; } = new(); + + [BsonElement("publishedAt")] + public DateTime PublishedAt { get; set; } + + [BsonElement("lastSyncAt")] + public DateTime LastSyncAt { get; set; } + + [BsonElement("createdAt")] + public DateTime CreatedAt { get; set; } + + public string FullUrl => $"page/{Category}/{Slug}"; +} + +public class LivePageAnalytics +{ + [BsonElement("totalViews")] + public long TotalViews { get; set; } + + [BsonElement("totalClicks")] + public long TotalClicks { get; set; } + + [BsonElement("lastViewedAt")] + public DateTime? LastViewedAt { get; set; } +} \ No newline at end of file diff --git a/src/BCards.Web/Models/UserPage.cs b/src/BCards.Web/Models/UserPage.cs index 37314bd..bb08539 100644 --- a/src/BCards.Web/Models/UserPage.cs +++ b/src/BCards.Web/Models/UserPage.cs @@ -4,7 +4,7 @@ using BCards.Web.ViewModels; namespace BCards.Web.Models; -public class UserPage +public class UserPage : IPageDisplay { [BsonId] [BsonRepresentation(BsonType.ObjectId)] diff --git a/src/BCards.Web/Program.cs b/src/BCards.Web/Program.cs index 16d1a27..9ef4918 100644 --- a/src/BCards.Web/Program.cs +++ b/src/BCards.Web/Program.cs @@ -126,6 +126,10 @@ builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); +// 🔥 NOVO: LivePage Services +builder.Services.AddScoped(); +builder.Services.AddScoped(); + // Add HttpClient for OpenGraphService builder.Services.AddHttpClient(); @@ -205,10 +209,11 @@ app.MapControllerRoute( // defaults: new { controller = "UserPage", action = "Display" }, // constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" }); +// 🔥 NOVA ROTA: LivePageController para páginas otimizadas de SEO app.MapControllerRoute( - name: "userpage", + name: "livepage", pattern: "page/{category}/{slug}", - defaults: new { controller = "UserPage", action = "Display" }, + defaults: new { controller = "LivePage", action = "Display" }, constraints: new { category = @"^[a-zA-Z0-9\-\u00C0-\u017F]+$", // ← Aceita acentos @@ -250,4 +255,7 @@ using (var scope = app.Services.CreateScope()) } } -app.Run(); \ No newline at end of file +app.Run(); + +// Make Program accessible for integration tests +public partial class Program { } \ No newline at end of file diff --git a/src/BCards.Web/Repositories/ILivePageRepository.cs b/src/BCards.Web/Repositories/ILivePageRepository.cs new file mode 100644 index 0000000..2283e5b --- /dev/null +++ b/src/BCards.Web/Repositories/ILivePageRepository.cs @@ -0,0 +1,17 @@ +using BCards.Web.Models; + +namespace BCards.Web.Repositories; + +public interface ILivePageRepository +{ + Task GetByCategoryAndSlugAsync(string category, string slug); + Task GetByOriginalPageIdAsync(string originalPageId); + Task> GetAllActiveAsync(); + Task CreateAsync(LivePage livePage); + Task UpdateAsync(LivePage livePage); + Task DeleteAsync(string id); + Task DeleteByOriginalPageIdAsync(string originalPageId); + Task ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null); + Task IncrementViewAsync(string id); + Task IncrementLinkClickAsync(string id, int linkIndex); +} \ No newline at end of file diff --git a/src/BCards.Web/Repositories/LivePageRepository.cs b/src/BCards.Web/Repositories/LivePageRepository.cs new file mode 100644 index 0000000..8b91ba4 --- /dev/null +++ b/src/BCards.Web/Repositories/LivePageRepository.cs @@ -0,0 +1,122 @@ +using BCards.Web.Models; +using MongoDB.Driver; + +namespace BCards.Web.Repositories; + +public class LivePageRepository : ILivePageRepository +{ + private readonly IMongoCollection _collection; + + public LivePageRepository(IMongoDatabase database) + { + _collection = database.GetCollection("livepages"); + + // Criar índices essenciais + CreateIndexes(); + } + + private void CreateIndexes() + { + try + { + // Índice único para category + slug + var categorySlugIndex = Builders.IndexKeys + .Ascending(x => x.Category) + .Ascending(x => x.Slug); + + var uniqueOptions = new CreateIndexOptions { Unique = true }; + _collection.Indexes.CreateOneAsync(new CreateIndexModel(categorySlugIndex, uniqueOptions)); + + // Outros índices importantes + _collection.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.UserId))); + + _collection.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Descending(x => x.PublishedAt))); + + _collection.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.OriginalPageId))); + } + catch + { + // Ignora erros de criação de índices (já podem existir) + } + } + + public async Task GetByCategoryAndSlugAsync(string category, string slug) + { + return await _collection.Find(x => x.Category == category && x.Slug == slug).FirstOrDefaultAsync(); + } + + public async Task GetByOriginalPageIdAsync(string originalPageId) + { + return await _collection.Find(x => x.OriginalPageId == originalPageId).FirstOrDefaultAsync(); + } + + public async Task> GetAllActiveAsync() + { + return await _collection.Find(x => true) + .Sort(Builders.Sort.Descending(x => x.PublishedAt)) + .ToListAsync(); + } + + public async Task CreateAsync(LivePage livePage) + { + livePage.CreatedAt = DateTime.UtcNow; + livePage.LastSyncAt = DateTime.UtcNow; + await _collection.InsertOneAsync(livePage); + return livePage; + } + + public async Task UpdateAsync(LivePage livePage) + { + livePage.LastSyncAt = DateTime.UtcNow; + await _collection.ReplaceOneAsync(x => x.Id == livePage.Id, livePage); + return livePage; + } + + public async Task DeleteAsync(string id) + { + var result = await _collection.DeleteOneAsync(x => x.Id == id); + return result.DeletedCount > 0; + } + + public async Task DeleteByOriginalPageIdAsync(string originalPageId) + { + var result = await _collection.DeleteOneAsync(x => x.OriginalPageId == originalPageId); + return result.DeletedCount > 0; + } + + public async Task ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null) + { + var filter = Builders.Filter.And( + Builders.Filter.Eq(x => x.Category, category), + Builders.Filter.Eq(x => x.Slug, slug) + ); + + if (!string.IsNullOrEmpty(excludeId)) + { + filter = Builders.Filter.And(filter, + Builders.Filter.Ne(x => x.Id, excludeId)); + } + + return await _collection.Find(filter).AnyAsync(); + } + + public async Task IncrementViewAsync(string id) + { + var update = Builders.Update + .Inc(x => x.Analytics.TotalViews, 1) + .Set(x => x.Analytics.LastViewedAt, DateTime.UtcNow); + + await _collection.UpdateOneAsync(x => x.Id == id, update); + } + + public async Task IncrementLinkClickAsync(string id, int linkIndex) + { + var update = Builders.Update + .Inc(x => x.Analytics.TotalClicks, 1); + + await _collection.UpdateOneAsync(x => x.Id == id, update); + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/ILivePageService.cs b/src/BCards.Web/Services/ILivePageService.cs new file mode 100644 index 0000000..b0b3a37 --- /dev/null +++ b/src/BCards.Web/Services/ILivePageService.cs @@ -0,0 +1,13 @@ +using BCards.Web.Models; + +namespace BCards.Web.Services; + +public interface ILivePageService +{ + Task GetByCategoryAndSlugAsync(string category, string slug); + Task> GetAllActiveAsync(); + Task SyncFromUserPageAsync(string userPageId); + Task DeleteByOriginalPageIdAsync(string originalPageId); + Task IncrementViewAsync(string livePageId); + Task IncrementLinkClickAsync(string livePageId, int linkIndex); +} \ No newline at end of file diff --git a/src/BCards.Web/Services/IPaymentService.cs b/src/BCards.Web/Services/IPaymentService.cs index ea8cd0c..98ce2e6 100644 --- a/src/BCards.Web/Services/IPaymentService.cs +++ b/src/BCards.Web/Services/IPaymentService.cs @@ -12,4 +12,9 @@ public interface IPaymentService Task CancelSubscriptionAsync(string subscriptionId); Task UpdateSubscriptionAsync(string subscriptionId, string newPriceId); Task GetPlanLimitationsAsync(string planType); + + // Novos métodos para gerenciamento de assinatura + Task GetSubscriptionDetailsAsync(string userId); + Task> GetPaymentHistoryAsync(string userId); + Task CreatePortalSessionAsync(string customerId, string returnUrl); } \ No newline at end of file diff --git a/src/BCards.Web/Services/LivePageService.cs b/src/BCards.Web/Services/LivePageService.cs new file mode 100644 index 0000000..38f4431 --- /dev/null +++ b/src/BCards.Web/Services/LivePageService.cs @@ -0,0 +1,113 @@ +using BCards.Web.Models; +using BCards.Web.Repositories; +using BCards.Web.ViewModels; + +namespace BCards.Web.Services; + +public class LivePageService : ILivePageService +{ + private readonly ILivePageRepository _livePageRepository; + private readonly IUserPageRepository _userPageRepository; + private readonly ILogger _logger; + + public LivePageService( + ILivePageRepository livePageRepository, + IUserPageRepository userPageRepository, + ILogger logger) + { + _livePageRepository = livePageRepository; + _userPageRepository = userPageRepository; + _logger = logger; + } + + public async Task GetByCategoryAndSlugAsync(string category, string slug) + { + return await _livePageRepository.GetByCategoryAndSlugAsync(category, slug); + } + + public async Task> GetAllActiveAsync() + { + return await _livePageRepository.GetAllActiveAsync(); + } + + public async Task SyncFromUserPageAsync(string userPageId) + { + var userPage = await _userPageRepository.GetByIdAsync(userPageId); + if (userPage == null) + throw new InvalidOperationException($"UserPage {userPageId} not found"); + + if (userPage.Status != PageStatus.Active) + throw new InvalidOperationException("UserPage must be Active to sync to LivePage"); + + // Verificar se já existe LivePage para este UserPage + var existingLivePage = await _livePageRepository.GetByOriginalPageIdAsync(userPageId); + + var livePage = new LivePage + { + OriginalPageId = userPageId, + UserId = userPage.UserId, + Category = userPage.Category, + Slug = userPage.Slug, + DisplayName = userPage.DisplayName, + Bio = userPage.Bio, + ProfileImage = userPage.ProfileImage, + BusinessType = userPage.BusinessType, + Theme = userPage.Theme, + Links = userPage.Links, + SeoSettings = userPage.SeoSettings, + Language = userPage.Language, + Analytics = new LivePageAnalytics + { + TotalViews = existingLivePage?.Analytics?.TotalViews ?? 0, + TotalClicks = existingLivePage?.Analytics?.TotalClicks ?? 0, + LastViewedAt = existingLivePage?.Analytics?.LastViewedAt + }, + PublishedAt = userPage.ApprovedAt ?? DateTime.UtcNow + }; + + if (existingLivePage != null) + { + // Atualizar existente + livePage.Id = existingLivePage.Id; + livePage.CreatedAt = existingLivePage.CreatedAt; + _logger.LogInformation("Updating existing LivePage {LivePageId} from UserPage {UserPageId}", livePage.Id, userPageId); + return await _livePageRepository.UpdateAsync(livePage); + } + else + { + // Criar nova + _logger.LogInformation("Creating new LivePage from UserPage {UserPageId}", userPageId); + return await _livePageRepository.CreateAsync(livePage); + } + } + + public async Task DeleteByOriginalPageIdAsync(string originalPageId) + { + _logger.LogInformation("Deleting LivePage for UserPage {UserPageId}", originalPageId); + return await _livePageRepository.DeleteByOriginalPageIdAsync(originalPageId); + } + + public async Task IncrementViewAsync(string livePageId) + { + try + { + await _livePageRepository.IncrementViewAsync(livePageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to increment view for LivePage {LivePageId}", livePageId); + } + } + + public async Task IncrementLinkClickAsync(string livePageId, int linkIndex) + { + try + { + await _livePageRepository.IncrementLinkClickAsync(livePageId, linkIndex); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to increment click for LivePage {LivePageId} link {LinkIndex}", livePageId, linkIndex); + } + } +} \ No newline at end of file diff --git a/src/BCards.Web/Services/ModerationService.cs b/src/BCards.Web/Services/ModerationService.cs index 2680241..960d804 100644 --- a/src/BCards.Web/Services/ModerationService.cs +++ b/src/BCards.Web/Services/ModerationService.cs @@ -9,15 +9,18 @@ public class ModerationService : IModerationService { private readonly IUserPageRepository _userPageRepository; private readonly IUserRepository _userRepository; + private readonly ILivePageService _livePageService; private readonly ILogger _logger; public ModerationService( IUserPageRepository userPageRepository, IUserRepository userRepository, + ILivePageService livePageService, ILogger logger) { _userPageRepository = userPageRepository; _userRepository = userRepository; + _livePageService = livePageService; _logger = logger; } @@ -105,6 +108,18 @@ public class ModerationService : IModerationService await _userPageRepository.UpdateAsync(pageId, update); _logger.LogInformation("Page {PageId} approved by moderator {ModeratorId}", pageId, moderatorId); + + // 🔥 NOVA FUNCIONALIDADE: Sincronizar para LivePage + try + { + await _livePageService.SyncFromUserPageAsync(pageId); + _logger.LogInformation("Page {PageId} synced to LivePages successfully", pageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to sync page {PageId} to LivePages. Approval completed but sync failed.", pageId); + // Não falhar a aprovação se sync falhar + } } public async Task RejectPageAsync(string pageId, string moderatorId, string reason, List issues) @@ -139,6 +154,17 @@ public class ModerationService : IModerationService await _userPageRepository.UpdateAsync(pageId, update); _logger.LogInformation("Page {PageId} rejected by moderator {ModeratorId}. Reason: {Reason}", pageId, moderatorId, reason); + + // Remover da LivePages se existir + try + { + await _livePageService.DeleteByOriginalPageIdAsync(pageId); + _logger.LogInformation("LivePage removed for rejected UserPage {PageId}", pageId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to remove LivePage for UserPage {PageId}", pageId); + } } public async Task CanUserCreatePageAsync(string userId) diff --git a/src/BCards.Web/Services/PaymentService.cs b/src/BCards.Web/Services/PaymentService.cs index bdbccb8..8a5061f 100644 --- a/src/BCards.Web/Services/PaymentService.cs +++ b/src/BCards.Web/Services/PaymentService.cs @@ -4,6 +4,7 @@ using BCards.Web.Repositories; using Microsoft.Extensions.Options; using Stripe; using Stripe.Checkout; +using Stripe.BillingPortal; namespace BCards.Web.Services; @@ -41,7 +42,7 @@ public class PaymentService : IPaymentService var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name); - var options = new SessionCreateOptions + var options = new Stripe.Checkout.SessionCreateOptions { PaymentMethodTypes = new List { "card" }, Mode = "subscription", @@ -63,7 +64,7 @@ public class PaymentService : IPaymentService } }; - var service = new SessionService(); + var service = new Stripe.Checkout.SessionService(); var session = await service.CreateAsync(options); return session.Url; @@ -119,7 +120,7 @@ public class PaymentService : IPaymentService switch (stripeEvent.Type) { case Events.CheckoutSessionCompleted: - var session = stripeEvent.Data.Object as Session; + var session = stripeEvent.Data.Object as Stripe.Checkout.Session; await HandleCheckoutSessionCompletedAsync(session!); break; @@ -246,7 +247,7 @@ public class PaymentService : IPaymentService return Task.FromResult(limitations); } - private async Task HandleCheckoutSessionCompletedAsync(Session session) + private async Task HandleCheckoutSessionCompletedAsync(Stripe.Checkout.Session session) { var userId = session.Metadata["user_id"]; var planType = session.Metadata["plan_type"]; @@ -319,4 +320,70 @@ public class PaymentService : IPaymentService } } } + + public async Task GetSubscriptionDetailsAsync(string userId) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null || string.IsNullOrEmpty(user.StripeCustomerId)) + return null; + + var subscription = await _subscriptionRepository.GetByUserIdAsync(userId); + if (subscription == null || string.IsNullOrEmpty(subscription.StripeSubscriptionId)) + return null; + + try + { + var service = new SubscriptionService(); + return await service.GetAsync(subscription.StripeSubscriptionId); + } + catch (StripeException) + { + return null; + } + } + + public async Task> GetPaymentHistoryAsync(string userId) + { + var user = await _userRepository.GetByIdAsync(userId); + if (user == null || string.IsNullOrEmpty(user.StripeCustomerId)) + return new List(); + + try + { + var service = new InvoiceService(); + var options = new InvoiceListOptions + { + Customer = user.StripeCustomerId, + Limit = 50, // Últimas 50 faturas + Status = "paid" + }; + + var invoices = await service.ListAsync(options); + return invoices.Data; + } + catch (StripeException) + { + return new List(); + } + } + + public async Task CreatePortalSessionAsync(string customerId, string returnUrl) + { + try + { + var options = new Stripe.BillingPortal.SessionCreateOptions + { + Customer = customerId, + ReturnUrl = returnUrl + }; + + var service = new Stripe.BillingPortal.SessionService(); + var session = await service.CreateAsync(options); + return session.Url; + } + catch (StripeException ex) + { + throw new InvalidOperationException($"Erro ao criar sessão do portal: {ex.Message}"); + } + } } \ No newline at end of file diff --git a/src/BCards.Web/Services/UserPageService.cs b/src/BCards.Web/Services/UserPageService.cs index 7c375ee..33a22c0 100644 --- a/src/BCards.Web/Services/UserPageService.cs +++ b/src/BCards.Web/Services/UserPageService.cs @@ -3,6 +3,7 @@ using BCards.Web.Repositories; using System.Text.RegularExpressions; using System.Globalization; using System.Text; +using BCards.Web.Utils; namespace BCards.Web.Services; @@ -76,7 +77,7 @@ public class UserPageService : IUserPageService public async Task GenerateSlugAsync(string category, string name) { - var slug = GenerateSlug(name); + var slug = SlugHelper.CreateSlug(GenerateSlug(name)); var originalSlug = slug; var counter = 1; diff --git a/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs b/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs new file mode 100644 index 0000000..fc052a0 --- /dev/null +++ b/src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs @@ -0,0 +1,83 @@ +using BCards.Web.Models; +using Stripe; + +namespace BCards.Web.ViewModels; + +public class ManageSubscriptionViewModel +{ + public User User { get; set; } = new(); + public Stripe.Subscription? StripeSubscription { get; set; } + public Models.Subscription? LocalSubscription { get; set; } + public List PaymentHistory { get; set; } = new(); + public List AvailablePlans { get; set; } = new(); + public string? ErrorMessage { get; set; } + public string? SuccessMessage { get; set; } + + // Propriedades calculadas + public bool HasActiveSubscription => StripeSubscription?.Status == "active"; + public bool CanUpgrade => HasActiveSubscription && User.CurrentPlan != "premium"; + public bool CanDowngrade => HasActiveSubscription && User.CurrentPlan != "basic"; + public bool WillCancelAtPeriodEnd => StripeSubscription?.CancelAtPeriodEnd == true; + + public DateTime? CurrentPeriodEnd => StripeSubscription?.CurrentPeriodEnd; + public DateTime? NextBillingDate => !WillCancelAtPeriodEnd ? CurrentPeriodEnd : null; + + public decimal? MonthlyAmount => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.UnitAmount / 100m; + public string? Currency => StripeSubscription?.Items?.Data?.FirstOrDefault()?.Price?.Currency?.ToUpper(); + + public string StatusDisplayName => (StripeSubscription?.Status) switch + { + "active" => "Ativa", + "past_due" => "Em atraso", + "canceled" => "Cancelada", + "unpaid" => "Não paga", + "incomplete" => "Incompleta", + "incomplete_expired" => "Expirada", + "trialing" => "Em período de teste", + _ => "Desconhecido" + }; + + public string PlanDisplayName => User.CurrentPlan switch + { + "basic" => "Básico", + "professional" => "Profissional", + "premium" => "Premium", + _ => "Gratuito" + }; +} + +public class AvailablePlanViewModel +{ + public string PlanType { get; set; } = string.Empty; + public string DisplayName { get; set; } = string.Empty; + public decimal Price { get; set; } + public string PriceId { get; set; } = string.Empty; + public int MaxLinks { get; set; } + public bool AllowCustomThemes { get; set; } + public bool AllowAnalytics { get; set; } + public bool AllowCustomDomain { get; set; } + public bool IsCurrentPlan { get; set; } + public bool IsUpgrade { get; set; } + public bool IsDowngrade { get; set; } + public List Features { get; set; } = new(); +} + +public class PaymentHistoryItemViewModel +{ + public string InvoiceId { get; set; } = string.Empty; + public DateTime Date { get; set; } + public decimal Amount { get; set; } + public string Currency { get; set; } = string.Empty; + public string Status { get; set; } = string.Empty; + public string Description { get; set; } = string.Empty; + public string? ReceiptUrl { get; set; } + + public string StatusDisplayName => Status switch + { + "paid" => "Pago", + "open" => "Em aberto", + "void" => "Cancelado", + "uncollectible" => "Incobrável", + _ => "Desconhecido" + }; +} \ No newline at end of file diff --git a/src/BCards.Web/Views/Admin/Dashboard.cshtml b/src/BCards.Web/Views/Admin/Dashboard.cshtml index fd2c5aa..eb4f0ac 100644 --- a/src/BCards.Web/Views/Admin/Dashboard.cshtml +++ b/src/BCards.Web/Views/Admin/Dashboard.cshtml @@ -25,7 +25,7 @@
@foreach (var pageItem in Model.UserPages) { -
+
@@ -37,6 +37,7 @@ +

@(pageItem.Category)/@(pageItem.Slug)

@@ -92,56 +93,82 @@
} -
- - - Editar - - - - @if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating || - pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected) +
+ + @if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Active) { - - - - - - } - else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration) - { - - - - Aguardando - - } - else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Active) - { - - + Ver } + else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating || + pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected || + pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration) + { + + } + + +
+ + + @if ((pageItem.LastModerationStatus ?? pageItem.Status) == BCards.Web.ViewModels.PageStatus.Creating) + { +
+
+ +
+ Página em criação! + Você pode editar e fazer preview quantas vezes quiser.
+ Ao terminar, clique em para enviar a página para moderação! + +
+
+
+ + + } + } @if (Model.CanCreateNewPage) { -
+
@@ -228,6 +278,7 @@
} +
diff --git a/src/BCards.Web/Views/Admin/ManagePage.cshtml b/src/BCards.Web/Views/Admin/ManagePage.cshtml index 1fedd0e..a57e256 100644 --- a/src/BCards.Web/Views/Admin/ManagePage.cshtml +++ b/src/BCards.Web/Views/Admin/ManagePage.cshtml @@ -1,3 +1,4 @@ +@using BCards.Web.Utils @model BCards.Web.ViewModels.ManagePageViewModel @{ ViewData["Title"] = Model.IsNewPage ? "Criar Página" : "Editar Página"; @@ -90,7 +91,7 @@
page/ - categoria + @SlugHelper.CreateCategorySlug(Model.Category) / @@ -860,7 +861,7 @@ .done(function(data) { $('#Slug').val(data.slug); $('#slugPreview').val(data.slug); - $('#categorySlug').text(category); + $('#categorySlug').text(data.category); }); } } diff --git a/src/BCards.Web/Views/Payment/ManageSubscription.cshtml b/src/BCards.Web/Views/Payment/ManageSubscription.cshtml new file mode 100644 index 0000000..30db142 --- /dev/null +++ b/src/BCards.Web/Views/Payment/ManageSubscription.cshtml @@ -0,0 +1,291 @@ +@model BCards.Web.ViewModels.ManageSubscriptionViewModel +@{ + ViewData["Title"] = "Gerenciar Assinatura"; + Layout = "_Layout"; +} + +
+
+
+ + +
+

+ + Gerenciar Assinatura +

+ + + Voltar ao Dashboard + +
+ + + @if (!string.IsNullOrEmpty(Model.ErrorMessage)) + { + + } + + @if (!string.IsNullOrEmpty(Model.SuccessMessage)) + { + + } + + @if (!string.IsNullOrEmpty(TempData["Error"]?.ToString())) + { + + } + + @if (!string.IsNullOrEmpty(TempData["Success"]?.ToString())) + { + + } + + +
+
+
+ + Assinatura Atual +
+
+
+ @if (Model.HasActiveSubscription) + { +
+
+

Plano @Model.PlanDisplayName

+

+ Status: @Model.StatusDisplayName +

+ @if (Model.MonthlyAmount.HasValue) + { +

+ R$ @Model.MonthlyAmount.Value.ToString("F2") / mês +

+ } +
+
+ @if (Model.NextBillingDate.HasValue) + { +

+ + Próxima cobrança: @Model.NextBillingDate.Value.ToString("dd/MM/yyyy") +

+ } + @if (Model.WillCancelAtPeriodEnd) + { +

+ + Assinatura será cancelada em @Model.CurrentPeriodEnd?.ToString("dd/MM/yyyy") +

+ } +
+
+ +
+
+ @if (!Model.WillCancelAtPeriodEnd) + { + + } + +
+ +
+
+
+ } + else + { +
+ +
Nenhuma assinatura ativa
+

Você está usando o plano gratuito. Faça upgrade para desbloquear mais recursos!

+ + + Ver Planos + +
+ } +
+
+ + + @if (Model.HasActiveSubscription && (Model.CanUpgrade || Model.CanDowngrade)) + { +
+
+
+ + Alterar Plano +
+
+
+
+ @foreach (var plan in Model.AvailablePlans.Where(p => !p.IsCurrentPlan)) + { +
+
+
+
@plan.DisplayName
+

R$ @plan.Price.ToString("F2")

+
    + @foreach (var feature in plan.Features) + { +
  • + + @feature +
  • + } +
+ +
+ + +
+
+
+
+ } +
+
+
+ } + + + @if (Model.PaymentHistory.Any()) + { +
+
+
+ + Histórico de Pagamentos +
+
+
+
+ + + + + + + + + + + + @foreach (var invoice in Model.PaymentHistory.Take(10)) + { + + + + + + + + } + +
DataDescriçãoValorStatusRecibo
@invoice.Created.ToString("dd/MM/yyyy") + @if (!string.IsNullOrEmpty(invoice.Description)) + { + @invoice.Description + } + else + { + Assinatura @Model.PlanDisplayName + } + + R$ @((invoice.AmountPaid / 100m).ToString("F2")) + + Pago + + @if (!string.IsNullOrEmpty(invoice.HostedInvoiceUrl)) + { + + + Ver + + } +
+
+
+
+ } +
+
+
+ + + + +@section Scripts { + +} \ No newline at end of file diff --git a/src/BCards.Web/Views/Shared/_Layout.cshtml b/src/BCards.Web/Views/Shared/_Layout.cshtml index 8027c2a..b5639a4 100644 --- a/src/BCards.Web/Views/Shared/_Layout.cshtml +++ b/src/BCards.Web/Views/Shared/_Layout.cshtml @@ -31,6 +31,8 @@ } + @await RenderSectionAsync("Head", required: false) + diff --git a/src/BCards.Web/Views/Shared/_UserPageLayout.cshtml b/src/BCards.Web/Views/Shared/_UserPageLayout.cshtml index d288596..6113d12 100644 --- a/src/BCards.Web/Views/Shared/_UserPageLayout.cshtml +++ b/src/BCards.Web/Views/Shared/_UserPageLayout.cshtml @@ -41,6 +41,8 @@ } + @await RenderSectionAsync("Head", required: false) + diff --git a/src/BCards.Web/Views/UserPage/Display.cshtml b/src/BCards.Web/Views/UserPage/Display.cshtml index ad596f3..4506cbd 100644 --- a/src/BCards.Web/Views/UserPage/Display.cshtml +++ b/src/BCards.Web/Views/UserPage/Display.cshtml @@ -1,13 +1,33 @@ -@model BCards.Web.Models.UserPage +@model BCards.Web.Models.IPageDisplay @{ var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings; var category = ViewBag.Category as BCards.Web.Models.Category; var isPreview = ViewBag.IsPreview as bool? ?? false; + var isLivePage = ViewBag.IsLivePage as bool? ?? false; ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}"; Layout = isPreview ? "_Layout" : "_UserPageLayout"; } +@section Head { + @if (isPreview) + { + + } + else if (isLivePage) + { + + @if (!string.IsNullOrEmpty(ViewBag.PageUrl as string)) + { + + } + } + else + { + + } +} + @section Styles {