feat/live-preview #8

Merged
ricardo merged 43 commits from feat/live-preview into main 2025-08-18 00:50:03 +00:00
41 changed files with 3165 additions and 82 deletions
Showing only changes of commit c933510348 - Show all commits

View File

@ -17,7 +17,8 @@
"Bash(sudo rm:*)", "Bash(sudo rm:*)",
"Bash(rm:*)", "Bash(rm:*)",
"Bash(curl:*)", "Bash(curl:*)",
"Bash(docker-compose up:*)" "Bash(docker-compose up:*)",
"Bash(dotnet build:*)"
] ]
}, },
"enableAllProjectMcpServers": false "enableAllProjectMcpServers": false

View File

@ -4,7 +4,7 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}"
EndProject 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 EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution 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}.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.ActiveCfg = Release|Any CPU
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = 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 {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.Build.0 = Debug|Any CPU {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.ActiveCfg = Release|Any CPU {8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.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}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE

View File

@ -0,0 +1,45 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.9.0" />
<PackageReference Include="xunit" Version="2.6.6" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.6">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.0" />
<PackageReference Include="PuppeteerSharp" Version="13.0.2" />
<PackageReference Include="MongoDB.Driver" Version="2.25.0" />
<PackageReference Include="Testcontainers.MongoDb" Version="3.6.0" />
<PackageReference Include="Stripe.net" Version="44.7.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.0" />
<PackageReference Include="Moq" Version="4.20.70" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BCards.Web\BCards.Web.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.Testing.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@ -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<Program>, 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<string, string?>
{
["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<IMongoClient>(serviceProvider =>
{
return new MongoClient(_mongoContainer.GetConnectionString());
});
services.AddScoped(serviceProvider =>
{
var client = serviceProvider.GetRequiredService<IMongoClient>();
TestDatabase = client.GetDatabase(TestDatabaseName);
return TestDatabase;
});
// Override Stripe settings for testing
services.Configure<StripeSettings>(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<IEmailService, MockEmailService>();
});
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<bool> SendEmailAsync(string to, string subject, string htmlContent)
{
return Task.FromResult(true);
}
}

View File

@ -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<Category>
{
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<User> 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<UserPage> 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<LinkItem>(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ModerationAttempts = 0,
ModerationHistory = new List<ModerationHistory>()
};
// 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<User> 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<List<UserPage>> GetUserPagesAsync(string userId)
{
var filter = Builders<UserPage>.Filter.Eq(p => p.UserId, userId);
var pages = await UserPageRepository.GetManyAsync(filter);
return pages.ToList();
}
public async Task<UserPage?> GetUserPageAsync(string category, string slug)
{
var filter = Builders<UserPage>.Filter.And(
Builders<UserPage>.Filter.Eq(p => p.Category, category),
Builders<UserPage>.Filter.Eq(p => p.Slug, slug)
);
var pages = await UserPageRepository.GetManyAsync(filter);
return pages.FirstOrDefault();
}
}

View File

@ -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<HttpClient> CreateAuthenticatedClientAsync(
WebApplicationFactory<Program> factory,
User testUser)
{
var client = factory.WithWebHostBuilder(builder =>
{
builder.ConfigureServices(services =>
{
services.AddAuthentication("Test")
.AddScheme<AuthenticationSchemeOptions, TestAuthenticationHandler>(
"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<Claim>
{
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<AuthenticationSchemeOptions>
{
public TestAuthenticationHandler(IOptionsMonitor<AuthenticationSchemeOptions> options,
ILoggerFactory logger, UrlEncoder encoder)
: base(options, logger, encoder)
{
}
protected override Task<AuthenticateResult> 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<Claim>
{
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));
}
}

View File

@ -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<Program> 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<string> GetPageContentAsync()
{
return await Page.GetContentAsync();
}
public async Task<string> GetPageTitleAsync()
{
return await Page.GetTitleAsync();
}
public async Task<bool> 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<string, string> 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<string> GetElementTextAsync(string selector)
{
await Page.WaitForSelectorAsync(selector);
var element = await Page.QuerySelectorAsync(selector);
var text = await Page.EvaluateFunctionAsync<string>("el => el.textContent", element);
return text?.Trim() ?? string.Empty;
}
public async Task<string> GetElementValueAsync(string selector)
{
await Page.WaitForSelectorAsync(selector);
var element = await Page.QuerySelectorAsync(selector);
var value = await Page.EvaluateFunctionAsync<string>("el => el.value", element);
return value ?? string.Empty;
}
public async Task<bool> 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<string> GetCurrentUrlAsync()
{
return Page.Url;
}
public async Task<List<string>> GetAllElementTextsAsync(string selector)
{
var elements = await Page.QuerySelectorAllAsync(selector);
var texts = new List<string>();
foreach (var element in elements)
{
var text = await Page.EvaluateFunctionAsync<string>("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();
}
}
}

View File

@ -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 <container-id> 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

View File

@ -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<BCardsWebApplicationFactory>, 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<IMongoDatabase>();
_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<IModerationService>();
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<IModerationService>();
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<string> { "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<IModerationService>();
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<IModerationService>();
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<IModerationService>();
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);
}
}

View File

@ -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<BCardsWebApplicationFactory>, 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<IMongoDatabase>();
_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");
}
}

View File

@ -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<BCardsWebApplicationFactory>, 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<IMongoDatabase>();
_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();
}
}

View File

@ -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"
}

View File

@ -18,6 +18,7 @@ public class AdminController : Controller
private readonly IThemeService _themeService; private readonly IThemeService _themeService;
private readonly IModerationService _moderationService; private readonly IModerationService _moderationService;
private readonly IEmailService _emailService; private readonly IEmailService _emailService;
private readonly ILivePageService _livePageService;
private readonly ILogger<AdminController> _logger; private readonly ILogger<AdminController> _logger;
public AdminController( public AdminController(
@ -27,6 +28,7 @@ public class AdminController : Controller
IThemeService themeService, IThemeService themeService,
IModerationService moderationService, IModerationService moderationService,
IEmailService emailService, IEmailService emailService,
ILivePageService livePageService,
ILogger<AdminController> logger) ILogger<AdminController> logger)
{ {
_authService = authService; _authService = authService;
@ -35,6 +37,7 @@ public class AdminController : Controller
_themeService = themeService; _themeService = themeService;
_moderationService = moderationService; _moderationService = moderationService;
_emailService = emailService; _emailService = emailService;
_livePageService = livePageService;
_logger = logger; _logger = logger;
} }
@ -248,7 +251,7 @@ public class AdminController : Controller
UpdateUserPageFromModel(existingPage, model); UpdateUserPageFromModel(existingPage, model);
// Set status to PendingModeration for updates // Set status to PendingModeration for updates
existingPage.Status = ViewModels.PageStatus.PendingModeration; existingPage.Status = ViewModels.PageStatus.Creating;
existingPage.ModerationAttempts = existingPage.ModerationAttempts; existingPage.ModerationAttempts = existingPage.ModerationAttempts;
await _userPageService.UpdatePageAsync(existingPage); await _userPageService.UpdatePageAsync(existingPage);
@ -493,10 +496,11 @@ public class AdminController : Controller
public async Task<IActionResult> GenerateSlug(string category, string name) public async Task<IActionResult> GenerateSlug(string category, string name)
{ {
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name))
return Json(new { slug = "" }); return Json(new { slug = "", category = "" });
var slug = await _userPageService.GenerateSlugAsync(category, name); 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] [HttpGet]
@ -884,4 +888,74 @@ public class AdminController : Controller
return Json(new { success = false, message = "Erro interno. Tente novamente." }); return Json(new { success = false, message = "Erro interno. Tente novamente." });
} }
} }
[HttpPost]
[Route("MigrateToLivePages")]
public async Task<IActionResult> 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<string>();
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}"
});
}
}
} }

View File

@ -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<LivePageController> _logger;
public LivePageController(ILivePageService livePageService, ILogger<LivePageController> logger)
{
_livePageService = livePageService;
_logger = logger;
}
[Route("{category}/{slug}")]
[ResponseCache(Duration = 3600, Location = ResponseCacheLocation.Any, VaryByQueryKeys = new string[] { })]
public async Task<IActionResult> 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<IActionResult> 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);
}
}

View File

@ -114,7 +114,7 @@ public class ModerationController : Controller
user.Email, user.Email,
user.Name, user.Name,
page.DisplayName, page.DisplayName,
"approved"); PageStatus.Active.ToString());
} }
TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!"; TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!";

View File

@ -1,5 +1,9 @@
using BCards.Web.Models;
using BCards.Web.Repositories;
using BCards.Web.Services; using BCards.Web.Services;
using BCards.Web.ViewModels;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Identity;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers; namespace BCards.Web.Controllers;
@ -9,11 +13,15 @@ public class PaymentController : Controller
{ {
private readonly IPaymentService _paymentService; private readonly IPaymentService _paymentService;
private readonly IAuthService _authService; 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; _paymentService = paymentService;
_authService = authService; _authService = authService;
_userService = userService;
_subscriptionRepository = subscriptionRepository;
} }
[HttpPost] [HttpPost]
@ -26,6 +34,8 @@ public class PaymentController : Controller
var successUrl = Url.Action("Success", "Payment", null, Request.Scheme); var successUrl = Url.Action("Success", "Payment", null, Request.Scheme);
var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme); var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme);
TempData[$"PlanType|{user.Id}"] = planType;
try try
{ {
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync( var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
@ -43,10 +53,29 @@ public class PaymentController : Controller
} }
} }
public IActionResult Success() public async Task<IActionResult> Success()
{ {
TempData["Success"] = "Assinatura ativada com sucesso! Agora você pode aproveitar todos os recursos do seu plano."; var user = await _authService.GetCurrentUserAsync(User);
return RedirectToAction("Dashboard", "Admin"); var planType = TempData[$"PlanType|{user.Id}"].ToString();
try
{
if (!string.IsNullOrEmpty(planType) && Enum.TryParse<PlanType>(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() public IActionResult Cancel()
@ -87,7 +116,36 @@ public class PaymentController : Controller
if (user == null) if (user == null)
return RedirectToAction("Login", "Auth"); 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] [HttpPost]
@ -105,4 +163,111 @@ public class PaymentController : Controller
return RedirectToAction("ManageSubscription"); return RedirectToAction("ManageSubscription");
} }
[HttpPost]
public async Task<IActionResult> 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<IActionResult> 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<AvailablePlanViewModel> GetAvailablePlans(string currentPlan)
{
var plans = new List<AvailablePlanViewModel>
{
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<string> { "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<string> { "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<string> { "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;
}
} }

View File

@ -8,11 +8,16 @@ namespace BCards.Web.Controllers;
public class SitemapController : Controller public class SitemapController : Controller
{ {
private readonly IUserPageService _userPageService; private readonly IUserPageService _userPageService;
private readonly ILivePageService _livePageService;
private readonly ILogger<SitemapController> _logger; private readonly ILogger<SitemapController> _logger;
public SitemapController(IUserPageService userPageService, ILogger<SitemapController> logger) public SitemapController(
IUserPageService userPageService,
ILivePageService livePageService,
ILogger<SitemapController> logger)
{ {
_userPageService = userPageService; _userPageService = userPageService;
_livePageService = livePageService;
_logger = logger; _logger = logger;
} }
@ -22,7 +27,8 @@ public class SitemapController : Controller
{ {
try try
{ {
var activePages = await _userPageService.GetActivePagesAsync(); // 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages
var livePages = await _livePageService.GetAllActiveAsync();
var sitemap = new XDocument( var sitemap = new XDocument(
new XDeclaration("1.0", "utf-8", "yes"), new XDeclaration("1.0", "utf-8", "yes"),
@ -43,11 +49,11 @@ public class SitemapController : Controller
new XElement("priority", "0.9") new XElement("priority", "0.9")
), ),
// Add user pages (only active ones) // Add live pages (SEO-optimized URLs only)
activePages.Select(page => livePages.Select(page =>
new XElement("url", new XElement("url",
new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"), 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("changefreq", "weekly"),
new XElement("priority", "0.8") 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); return Content(sitemap.ToString(), "application/xml", Encoding.UTF8);
} }

View File

@ -1,4 +1,6 @@
using BCards.Web.Models;
using BCards.Web.Services; using BCards.Web.Services;
using BCards.Web.Utils;
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
namespace BCards.Web.Controllers; namespace BCards.Web.Controllers;
@ -128,4 +130,5 @@ public class UserPageController : Controller
return View("Display", userPage); return View("Display", userPage);
} }
} }

View File

@ -0,0 +1,26 @@
namespace BCards.Web.Models
{
/// <summary>
/// Interface comum para páginas que podem ser exibidas publicamente
/// Facilita o envio de dados para views sem duplicação de código
/// </summary>
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<LinkItem> Links { get; }
SeoSettings SeoSettings { get; }
string Language { get; }
DateTime CreatedAt { get; }
// Propriedade calculada comum
string FullUrl { get; }
}
}

View File

@ -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<LinkItem> 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; }
}

View File

@ -4,7 +4,7 @@ using BCards.Web.ViewModels;
namespace BCards.Web.Models; namespace BCards.Web.Models;
public class UserPage public class UserPage : IPageDisplay
{ {
[BsonId] [BsonId]
[BsonRepresentation(BsonType.ObjectId)] [BsonRepresentation(BsonType.ObjectId)]

View File

@ -126,6 +126,10 @@ builder.Services.AddScoped<IOpenGraphService, OpenGraphService>();
builder.Services.AddScoped<IModerationService, ModerationService>(); builder.Services.AddScoped<IModerationService, ModerationService>();
builder.Services.AddScoped<IEmailService, EmailService>(); builder.Services.AddScoped<IEmailService, EmailService>();
// 🔥 NOVO: LivePage Services
builder.Services.AddScoped<ILivePageRepository, LivePageRepository>();
builder.Services.AddScoped<ILivePageService, LivePageService>();
// Add HttpClient for OpenGraphService // Add HttpClient for OpenGraphService
builder.Services.AddHttpClient<OpenGraphService>(); builder.Services.AddHttpClient<OpenGraphService>();
@ -205,10 +209,11 @@ app.MapControllerRoute(
// defaults: new { controller = "UserPage", action = "Display" }, // defaults: new { controller = "UserPage", action = "Display" },
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" }); // constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
// 🔥 NOVA ROTA: LivePageController para páginas otimizadas de SEO
app.MapControllerRoute( app.MapControllerRoute(
name: "userpage", name: "livepage",
pattern: "page/{category}/{slug}", pattern: "page/{category}/{slug}",
defaults: new { controller = "UserPage", action = "Display" }, defaults: new { controller = "LivePage", action = "Display" },
constraints: new constraints: new
{ {
category = @"^[a-zA-Z0-9\-\u00C0-\u017F]+$", // ← Aceita acentos category = @"^[a-zA-Z0-9\-\u00C0-\u017F]+$", // ← Aceita acentos
@ -251,3 +256,6 @@ using (var scope = app.Services.CreateScope())
} }
app.Run(); app.Run();
// Make Program accessible for integration tests
public partial class Program { }

View File

@ -0,0 +1,17 @@
using BCards.Web.Models;
namespace BCards.Web.Repositories;
public interface ILivePageRepository
{
Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId);
Task<List<LivePage>> GetAllActiveAsync();
Task<LivePage> CreateAsync(LivePage livePage);
Task<LivePage> UpdateAsync(LivePage livePage);
Task<bool> DeleteAsync(string id);
Task<bool> DeleteByOriginalPageIdAsync(string originalPageId);
Task<bool> ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null);
Task IncrementViewAsync(string id);
Task IncrementLinkClickAsync(string id, int linkIndex);
}

View File

@ -0,0 +1,122 @@
using BCards.Web.Models;
using MongoDB.Driver;
namespace BCards.Web.Repositories;
public class LivePageRepository : ILivePageRepository
{
private readonly IMongoCollection<LivePage> _collection;
public LivePageRepository(IMongoDatabase database)
{
_collection = database.GetCollection<LivePage>("livepages");
// Criar índices essenciais
CreateIndexes();
}
private void CreateIndexes()
{
try
{
// Índice único para category + slug
var categorySlugIndex = Builders<LivePage>.IndexKeys
.Ascending(x => x.Category)
.Ascending(x => x.Slug);
var uniqueOptions = new CreateIndexOptions { Unique = true };
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(categorySlugIndex, uniqueOptions));
// Outros índices importantes
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
Builders<LivePage>.IndexKeys.Ascending(x => x.UserId)));
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
Builders<LivePage>.IndexKeys.Descending(x => x.PublishedAt)));
_collection.Indexes.CreateOneAsync(new CreateIndexModel<LivePage>(
Builders<LivePage>.IndexKeys.Ascending(x => x.OriginalPageId)));
}
catch
{
// Ignora erros de criação de índices (já podem existir)
}
}
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
{
return await _collection.Find(x => x.Category == category && x.Slug == slug).FirstOrDefaultAsync();
}
public async Task<LivePage?> GetByOriginalPageIdAsync(string originalPageId)
{
return await _collection.Find(x => x.OriginalPageId == originalPageId).FirstOrDefaultAsync();
}
public async Task<List<LivePage>> GetAllActiveAsync()
{
return await _collection.Find(x => true)
.Sort(Builders<LivePage>.Sort.Descending(x => x.PublishedAt))
.ToListAsync();
}
public async Task<LivePage> CreateAsync(LivePage livePage)
{
livePage.CreatedAt = DateTime.UtcNow;
livePage.LastSyncAt = DateTime.UtcNow;
await _collection.InsertOneAsync(livePage);
return livePage;
}
public async Task<LivePage> UpdateAsync(LivePage livePage)
{
livePage.LastSyncAt = DateTime.UtcNow;
await _collection.ReplaceOneAsync(x => x.Id == livePage.Id, livePage);
return livePage;
}
public async Task<bool> DeleteAsync(string id)
{
var result = await _collection.DeleteOneAsync(x => x.Id == id);
return result.DeletedCount > 0;
}
public async Task<bool> DeleteByOriginalPageIdAsync(string originalPageId)
{
var result = await _collection.DeleteOneAsync(x => x.OriginalPageId == originalPageId);
return result.DeletedCount > 0;
}
public async Task<bool> ExistsByCategoryAndSlugAsync(string category, string slug, string? excludeId = null)
{
var filter = Builders<LivePage>.Filter.And(
Builders<LivePage>.Filter.Eq(x => x.Category, category),
Builders<LivePage>.Filter.Eq(x => x.Slug, slug)
);
if (!string.IsNullOrEmpty(excludeId))
{
filter = Builders<LivePage>.Filter.And(filter,
Builders<LivePage>.Filter.Ne(x => x.Id, excludeId));
}
return await _collection.Find(filter).AnyAsync();
}
public async Task IncrementViewAsync(string id)
{
var update = Builders<LivePage>.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<LivePage>.Update
.Inc(x => x.Analytics.TotalClicks, 1);
await _collection.UpdateOneAsync(x => x.Id == id, update);
}
}

View File

@ -0,0 +1,13 @@
using BCards.Web.Models;
namespace BCards.Web.Services;
public interface ILivePageService
{
Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug);
Task<List<LivePage>> GetAllActiveAsync();
Task<LivePage> SyncFromUserPageAsync(string userPageId);
Task<bool> DeleteByOriginalPageIdAsync(string originalPageId);
Task IncrementViewAsync(string livePageId);
Task IncrementLinkClickAsync(string livePageId, int linkIndex);
}

View File

@ -12,4 +12,9 @@ public interface IPaymentService
Task<bool> CancelSubscriptionAsync(string subscriptionId); Task<bool> CancelSubscriptionAsync(string subscriptionId);
Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId); Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId);
Task<PlanLimitations> GetPlanLimitationsAsync(string planType); Task<PlanLimitations> GetPlanLimitationsAsync(string planType);
// Novos métodos para gerenciamento de assinatura
Task<Stripe.Subscription?> GetSubscriptionDetailsAsync(string userId);
Task<List<Invoice>> GetPaymentHistoryAsync(string userId);
Task<string> CreatePortalSessionAsync(string customerId, string returnUrl);
} }

View File

@ -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<LivePageService> _logger;
public LivePageService(
ILivePageRepository livePageRepository,
IUserPageRepository userPageRepository,
ILogger<LivePageService> logger)
{
_livePageRepository = livePageRepository;
_userPageRepository = userPageRepository;
_logger = logger;
}
public async Task<LivePage?> GetByCategoryAndSlugAsync(string category, string slug)
{
return await _livePageRepository.GetByCategoryAndSlugAsync(category, slug);
}
public async Task<List<LivePage>> GetAllActiveAsync()
{
return await _livePageRepository.GetAllActiveAsync();
}
public async Task<LivePage> 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<bool> 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);
}
}
}

View File

@ -9,15 +9,18 @@ public class ModerationService : IModerationService
{ {
private readonly IUserPageRepository _userPageRepository; private readonly IUserPageRepository _userPageRepository;
private readonly IUserRepository _userRepository; private readonly IUserRepository _userRepository;
private readonly ILivePageService _livePageService;
private readonly ILogger<ModerationService> _logger; private readonly ILogger<ModerationService> _logger;
public ModerationService( public ModerationService(
IUserPageRepository userPageRepository, IUserPageRepository userPageRepository,
IUserRepository userRepository, IUserRepository userRepository,
ILivePageService livePageService,
ILogger<ModerationService> logger) ILogger<ModerationService> logger)
{ {
_userPageRepository = userPageRepository; _userPageRepository = userPageRepository;
_userRepository = userRepository; _userRepository = userRepository;
_livePageService = livePageService;
_logger = logger; _logger = logger;
} }
@ -105,6 +108,18 @@ public class ModerationService : IModerationService
await _userPageRepository.UpdateAsync(pageId, update); await _userPageRepository.UpdateAsync(pageId, update);
_logger.LogInformation("Page {PageId} approved by moderator {ModeratorId}", pageId, moderatorId); _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<string> issues) public async Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues)
@ -139,6 +154,17 @@ public class ModerationService : IModerationService
await _userPageRepository.UpdateAsync(pageId, update); await _userPageRepository.UpdateAsync(pageId, update);
_logger.LogInformation("Page {PageId} rejected by moderator {ModeratorId}. Reason: {Reason}", _logger.LogInformation("Page {PageId} rejected by moderator {ModeratorId}. Reason: {Reason}",
pageId, moderatorId, 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<bool> CanUserCreatePageAsync(string userId) public async Task<bool> CanUserCreatePageAsync(string userId)

View File

@ -4,6 +4,7 @@ using BCards.Web.Repositories;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Stripe; using Stripe;
using Stripe.Checkout; using Stripe.Checkout;
using Stripe.BillingPortal;
namespace BCards.Web.Services; namespace BCards.Web.Services;
@ -41,7 +42,7 @@ public class PaymentService : IPaymentService
var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name); var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name);
var options = new SessionCreateOptions var options = new Stripe.Checkout.SessionCreateOptions
{ {
PaymentMethodTypes = new List<string> { "card" }, PaymentMethodTypes = new List<string> { "card" },
Mode = "subscription", 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); var session = await service.CreateAsync(options);
return session.Url; return session.Url;
@ -119,7 +120,7 @@ public class PaymentService : IPaymentService
switch (stripeEvent.Type) switch (stripeEvent.Type)
{ {
case Events.CheckoutSessionCompleted: case Events.CheckoutSessionCompleted:
var session = stripeEvent.Data.Object as Session; var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
await HandleCheckoutSessionCompletedAsync(session!); await HandleCheckoutSessionCompletedAsync(session!);
break; break;
@ -246,7 +247,7 @@ public class PaymentService : IPaymentService
return Task.FromResult(limitations); 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 userId = session.Metadata["user_id"];
var planType = session.Metadata["plan_type"]; var planType = session.Metadata["plan_type"];
@ -319,4 +320,70 @@ public class PaymentService : IPaymentService
} }
} }
} }
public async Task<Stripe.Subscription?> 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<List<Invoice>> GetPaymentHistoryAsync(string userId)
{
var user = await _userRepository.GetByIdAsync(userId);
if (user == null || string.IsNullOrEmpty(user.StripeCustomerId))
return new List<Invoice>();
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<Invoice>();
}
}
public async Task<string> 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}");
}
}
} }

View File

@ -3,6 +3,7 @@ using BCards.Web.Repositories;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Globalization; using System.Globalization;
using System.Text; using System.Text;
using BCards.Web.Utils;
namespace BCards.Web.Services; namespace BCards.Web.Services;
@ -76,7 +77,7 @@ public class UserPageService : IUserPageService
public async Task<string> GenerateSlugAsync(string category, string name) public async Task<string> GenerateSlugAsync(string category, string name)
{ {
var slug = GenerateSlug(name); var slug = SlugHelper.CreateSlug(GenerateSlug(name));
var originalSlug = slug; var originalSlug = slug;
var counter = 1; var counter = 1;

View File

@ -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<Invoice> PaymentHistory { get; set; } = new();
public List<AvailablePlanViewModel> 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<string> 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"
};
}

View File

@ -25,7 +25,7 @@
<div class="row"> <div class="row">
@foreach (var pageItem in Model.UserPages) @foreach (var pageItem in Model.UserPages)
{ {
<div class="col-md-6 col-lg-4 mb-3"> <div class="col-md-6 col-lg-6 mb-4">
<div class="card h-100 @(pageItem.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")"> <div class="card h-100 @(pageItem.Status == BCards.Web.ViewModels.PageStatus.Active ? "" : "border-warning")">
<div class="card-body"> <div class="card-body">
<h6 class="card-title"> <h6 class="card-title">
@ -37,6 +37,7 @@
<i class="fas fa-trash"></i> <i class="fas fa-trash"></i>
</button> </button>
</form> </form>
<input type="hidden" id="displayName_@pageItem.Id" value="@(pageItem.DisplayName)" />
</h6> </h6>
<p class="text-muted small mb-2">@(pageItem.Category)/@(pageItem.Slug)</p> <p class="text-muted small mb-2">@(pageItem.Category)/@(pageItem.Slug)</p>
@ -92,56 +93,82 @@
</div> </div>
} }
<div class="d-flex gap-1 flex-wrap" data-page-id="@pageItem.Id" data-status="@pageItem.Status"> <div class="d-flex gap-1 align-items-center" data-page-id="@pageItem.Id" data-status="@pageItem.Status">
<!-- Botão Editar - sempre presente --> <!-- Botão Ver - sempre presente quando possível -->
<a href="@Url.Action("ManagePage", new { id = pageItem.Id })" @if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Active)
class="btn btn-sm btn-outline-primary">
<i class="fas fa-edit me-1"></i>Editar
</a>
<!-- Botões condicionais por status -->
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating ||
pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected)
{ {
<!-- Preview para páginas em desenvolvimento -->
<button type="button"
class="btn btn-sm btn-outline-info"
onclick="openPreview('@pageItem.Id')"
data-page-category="@pageItem.Category"
data-page-slug="@pageItem.Slug">
<i class="fas fa-eye me-1"></i>Preview
</button>
<!-- Botão Enviar para Moderação -->
<button type="button"
class="btn btn-sm btn-success"
onclick="submitForModeration('@pageItem.Id')"
data-page-name="@pageItem.DisplayName">
<i class="fas fa-paper-plane me-1"></i>Enviar
</button>
}
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
{
<!-- Preview para páginas em moderação -->
<button type="button"
class="btn btn-sm btn-outline-warning"
onclick="openPreview('@pageItem.Id')"
data-page-category="@pageItem.Category"
data-page-slug="@pageItem.Slug">
<i class="fas fa-clock me-1"></i>Preview
</button>
<span class="btn btn-sm btn-outline-secondary disabled">
<i class="fas fa-hourglass-half me-1"></i>Aguardando
</span>
}
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Active)
{
<!-- Ver página ativa -->
<a href="/page/@pageItem.Category/@pageItem.Slug" target="_blank" <a href="/page/@pageItem.Category/@pageItem.Slug" target="_blank"
class="btn btn-sm btn-outline-success"> class="btn btn-sm btn-success">
<i class="fas fa-external-link-alt me-1"></i>Ver <i class="fas fa-external-link-alt me-1"></i>Ver
</a> </a>
} }
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating ||
pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected ||
pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
{
<button type="button"
class="btn btn-sm btn-outline-info"
onclick="openPreview('@pageItem.Id')"
data-page-category="@pageItem.Category"
data-page-slug="@pageItem.Slug">
<i class="fas fa-eye me-1"></i>Preview
</button>
}
<!-- Dropdown para outras ações -->
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle"
type="button"
id="dropdownMenuButton@(pageItem.Id)"
data-bs-toggle="dropdown"
aria-expanded="false">
<i class="fas fa-ellipsis-v"></i>
</button>
<ul class="dropdown-menu" aria-labelledby="dropdownMenuButton@(pageItem.Id)">
<!-- Editar - sempre presente -->
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
{
<li>
<span class="dropdown-item disabled">
<i class="fas fa-edit me-2"></i>Editar
</span>
</li>
}
else
{
<li>
<a href="@Url.Action("ManagePage", new { id = pageItem.Id })"
class="dropdown-item">
<i class="fas fa-edit me-2"></i>Editar
</a>
</li>
}
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating ||
pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected)
{
<li><hr class="dropdown-divider"></li>
<li>
<button type="button"
class="dropdown-item"
onclick="submitForModeration('@pageItem.Id')"
data-page-name="@pageItem.DisplayName">
<i class="fas fa-paper-plane me-2"></i>Enviar para Moderação
</button>
</li>
}
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
{
<li><hr class="dropdown-divider"></li>
<li>
<span class="dropdown-item disabled">
<i class="fas fa-hourglass-half me-2"></i>Aguardando Moderação
</span>
</li>
}
</ul>
</div>
</div> </div>
</div> </div>
<div class="card-footer bg-transparent"> <div class="card-footer bg-transparent">
@ -176,12 +203,35 @@
</div> </div>
</div> </div>
</div> </div>
@if ((pageItem.LastModerationStatus ?? pageItem.Status) == BCards.Web.ViewModels.PageStatus.Creating)
{
<div class="col-12">
<div class="alert alert-secondary d-flex align-items-center alert-dismissible alert-permanent fade show">
<i class="fas fa-exclamation-triangle me-3"></i>
<div>
<strong>Página em criação!</strong>
Você pode editar e fazer preview quantas vezes quiser. <br />
Ao terminar, clique em <i class="fas fa-ellipsis-v"></i> para enviar a página <b><span id="pageNameDisplay"></span></b> para moderação!
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
</div>
<script>
var pageNameDisplay = document.getElementById('pageNameDisplay');
var displayName = document.getElementById('displayName_@pageItem.Id');
pageNameDisplay.innerHTML = displayName.value;
</script>
}
} }
<!-- Card para Criar Nova Página --> <!-- Card para Criar Nova Página -->
@if (Model.CanCreateNewPage) @if (Model.CanCreateNewPage)
{ {
<div class="col-md-6 col-lg-4 mb-3"> <div class="col-md-6 col-lg-4 mb-4">
<div class="card h-100 border-dashed text-center" style="border: 2px dashed #dee2e6;"> <div class="card h-100 border-dashed text-center" style="border: 2px dashed #dee2e6;">
<div class="card-body d-flex align-items-center justify-content-center"> <div class="card-body d-flex align-items-center justify-content-center">
<div> <div>
@ -228,6 +278,7 @@
</div> </div>
</div> </div>
} }
</div> </div>
</div> </div>

View File

@ -1,3 +1,4 @@
@using BCards.Web.Utils
@model BCards.Web.ViewModels.ManagePageViewModel @model BCards.Web.ViewModels.ManagePageViewModel
@{ @{
ViewData["Title"] = Model.IsNewPage ? "Criar Página" : "Editar Página"; ViewData["Title"] = Model.IsNewPage ? "Criar Página" : "Editar Página";
@ -90,7 +91,7 @@
<label for="slugPreview" class="form-label">URL da Página</label> <label for="slugPreview" class="form-label">URL da Página</label>
<div class="input-group"> <div class="input-group">
<span class="input-group-text">page/</span> <span class="input-group-text">page/</span>
<span class="input-group-text" id="categorySlug">categoria</span> <span class="input-group-text" id="categorySlug">@SlugHelper.CreateCategorySlug(Model.Category)</span>
<span class="input-group-text">/</span> <span class="input-group-text">/</span>
<input type="text" class="form-control" id="slugPreview" value="@Model.Slug" readonly> <input type="text" class="form-control" id="slugPreview" value="@Model.Slug" readonly>
<input asp-for="Slug" type="hidden"> <input asp-for="Slug" type="hidden">
@ -860,7 +861,7 @@
.done(function(data) { .done(function(data) {
$('#Slug').val(data.slug); $('#Slug').val(data.slug);
$('#slugPreview').val(data.slug); $('#slugPreview').val(data.slug);
$('#categorySlug').text(category); $('#categorySlug').text(data.category);
}); });
} }
} }

View File

@ -0,0 +1,291 @@
@model BCards.Web.ViewModels.ManageSubscriptionViewModel
@{
ViewData["Title"] = "Gerenciar Assinatura";
Layout = "_Layout";
}
<div class="container mt-4">
<div class="row">
<div class="col-lg-8 mx-auto">
<!-- Header -->
<div class="d-flex justify-content-between align-items-center mb-4">
<h2 class="mb-0">
<i class="fas fa-credit-card me-2"></i>
Gerenciar Assinatura
</h2>
<a href="@Url.Action("Dashboard", "Admin")" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i>
Voltar ao Dashboard
</a>
</div>
<!-- Alerts -->
@if (!string.IsNullOrEmpty(Model.ErrorMessage))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
@Model.ErrorMessage
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!string.IsNullOrEmpty(Model.SuccessMessage))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle me-2"></i>
@Model.SuccessMessage
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!string.IsNullOrEmpty(TempData["Error"]?.ToString()))
{
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<i class="fas fa-exclamation-triangle me-2"></i>
@TempData["Error"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
@if (!string.IsNullOrEmpty(TempData["Success"]?.ToString()))
{
<div class="alert alert-success alert-dismissible fade show" role="alert">
<i class="fas fa-check-circle me-2"></i>
@TempData["Success"]
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
}
<!-- Current Subscription Card -->
<div class="card mb-4">
<div class="card-header bg-primary text-white">
<h5 class="mb-0">
<i class="fas fa-star me-2"></i>
Assinatura Atual
</h5>
</div>
<div class="card-body">
@if (Model.HasActiveSubscription)
{
<div class="row">
<div class="col-md-6">
<h4 class="text-primary">Plano @Model.PlanDisplayName</h4>
<p class="text-muted mb-2">
Status: <span class="badge bg-success">@Model.StatusDisplayName</span>
</p>
@if (Model.MonthlyAmount.HasValue)
{
<p class="mb-2">
<strong>R$ @Model.MonthlyAmount.Value.ToString("F2")</strong> / mês
</p>
}
</div>
<div class="col-md-6">
@if (Model.NextBillingDate.HasValue)
{
<p class="mb-2">
<i class="fas fa-calendar me-2"></i>
Próxima cobrança: <strong>@Model.NextBillingDate.Value.ToString("dd/MM/yyyy")</strong>
</p>
}
@if (Model.WillCancelAtPeriodEnd)
{
<p class="text-warning mb-2">
<i class="fas fa-exclamation-triangle me-2"></i>
Assinatura será cancelada em @Model.CurrentPeriodEnd?.ToString("dd/MM/yyyy")
</p>
}
</div>
</div>
<div class="mt-3">
<div class="btn-group" role="group">
@if (!Model.WillCancelAtPeriodEnd)
{
<button type="button" class="btn btn-danger" data-bs-toggle="modal" data-bs-target="#cancelModal">
<i class="fas fa-times me-1"></i>
Cancelar Assinatura
</button>
}
<form method="post" action="@Url.Action("OpenStripePortal")" style="display: inline;">
<button type="submit" class="btn btn-outline-primary">
<i class="fas fa-external-link-alt me-1"></i>
Portal de Pagamento
</button>
</form>
</div>
</div>
}
else
{
<div class="text-center py-4">
<i class="fas fa-info-circle text-muted" style="font-size: 3rem;"></i>
<h5 class="mt-3">Nenhuma assinatura ativa</h5>
<p class="text-muted">Você está usando o plano gratuito. Faça upgrade para desbloquear mais recursos!</p>
<a href="@Url.Action("Pricing", "Home")" class="btn btn-primary">
<i class="fas fa-upgrade me-1"></i>
Ver Planos
</a>
</div>
}
</div>
</div>
<!-- Available Plans -->
@if (Model.HasActiveSubscription && (Model.CanUpgrade || Model.CanDowngrade))
{
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-exchange-alt me-2"></i>
Alterar Plano
</h5>
</div>
<div class="card-body">
<div class="row">
@foreach (var plan in Model.AvailablePlans.Where(p => !p.IsCurrentPlan))
{
<div class="col-md-4 mb-3">
<div class="card h-100 @(plan.IsUpgrade ? "border-success" : plan.IsDowngrade ? "border-warning" : "")">
<div class="card-body text-center">
<h5 class="card-title">@plan.DisplayName</h5>
<h4 class="text-primary mb-3">R$ @plan.Price.ToString("F2")</h4>
<ul class="list-unstyled text-start mb-3">
@foreach (var feature in plan.Features)
{
<li class="mb-1">
<i class="fas fa-check text-success me-2"></i>
@feature
</li>
}
</ul>
<form method="post" action="@Url.Action("ChangePlan")">
<input type="hidden" name="newPlanType" value="@plan.PlanType" />
<button type="submit" class="btn @(plan.IsUpgrade ? "btn-success" : "btn-warning") w-100">
<i class="fas @(plan.IsUpgrade ? "fa-arrow-up" : "fa-arrow-down") me-1"></i>
@(plan.IsUpgrade ? "Fazer Upgrade" : "Fazer Downgrade")
</button>
</form>
</div>
</div>
</div>
}
</div>
</div>
</div>
}
<!-- Payment History -->
@if (Model.PaymentHistory.Any())
{
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-history me-2"></i>
Histórico de Pagamentos
</h5>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Data</th>
<th>Descrição</th>
<th>Valor</th>
<th>Status</th>
<th>Recibo</th>
</tr>
</thead>
<tbody>
@foreach (var invoice in Model.PaymentHistory.Take(10))
{
<tr>
<td>@invoice.Created.ToString("dd/MM/yyyy")</td>
<td>
@if (!string.IsNullOrEmpty(invoice.Description))
{
@invoice.Description
}
else
{
<span>Assinatura @Model.PlanDisplayName</span>
}
</td>
<td>
<strong>R$ @((invoice.AmountPaid / 100m).ToString("F2"))</strong>
</td>
<td>
<span class="badge bg-success">Pago</span>
</td>
<td>
@if (!string.IsNullOrEmpty(invoice.HostedInvoiceUrl))
{
<a href="@invoice.HostedInvoiceUrl" target="_blank" class="btn btn-sm btn-outline-primary">
<i class="fas fa-download me-1"></i>
Ver
</a>
}
</td>
</tr>
}
</tbody>
</table>
</div>
</div>
</div>
}
</div>
</div>
</div>
<!-- Cancel Subscription Modal -->
<div class="modal fade" id="cancelModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-exclamation-triangle text-warning me-2"></i>
Cancelar Assinatura
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Tem certeza que deseja cancelar sua assinatura?</p>
<div class="alert alert-info">
<i class="fas fa-info-circle me-2"></i>
Sua assinatura permanecerá ativa até o final do período atual
(@Model.CurrentPeriodEnd?.ToString("dd/MM/yyyy")).
Após essa data, você retornará ao plano gratuito.
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
Manter Assinatura
</button>
@if (Model.StripeSubscription != null)
{
<form method="post" action="@Url.Action("CancelSubscription")" style="display: inline;">
<input type="hidden" name="subscriptionId" value="@Model.StripeSubscription.Id" />
<button type="submit" class="btn btn-danger">
<i class="fas fa-times me-1"></i>
Confirmar Cancelamento
</button>
</form>
}
</div>
</div>
</div>
</div>
@section Scripts {
<script>
// Auto-dismiss alerts after 5 seconds
setTimeout(function() {
$('.alert:not(.alert-dismissible)').fadeOut();
}, 5000);
</script>
}

View File

@ -31,6 +31,8 @@
<meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital" /> <meta name="keywords" content="linktree, links, página profissional, perfil, redes sociais, cartão digital" />
} }
@await RenderSectionAsync("Head", required: false)
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" /> <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />

View File

@ -41,6 +41,8 @@
</script> </script>
} }
@await RenderSectionAsync("Head", required: false)
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" /> <link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
<link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" /> <link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" />

View File

@ -1,13 +1,33 @@
@model BCards.Web.Models.UserPage @model BCards.Web.Models.IPageDisplay
@{ @{
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings; var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
var category = ViewBag.Category as BCards.Web.Models.Category; var category = ViewBag.Category as BCards.Web.Models.Category;
var isPreview = ViewBag.IsPreview as bool? ?? false; var isPreview = ViewBag.IsPreview as bool? ?? false;
var isLivePage = ViewBag.IsLivePage as bool? ?? false;
ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}"; ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}";
Layout = isPreview ? "_Layout" : "_UserPageLayout"; Layout = isPreview ? "_Layout" : "_UserPageLayout";
} }
@section Head {
@if (isPreview)
{
<meta name="robots" content="noindex, nofollow, noarchive, nosnippet">
}
else if (isLivePage)
{
<meta name="robots" content="index, follow">
@if (!string.IsNullOrEmpty(ViewBag.PageUrl as string))
{
<link rel="canonical" href="@ViewBag.PageUrl">
}
}
else
{
<meta name="robots" content="noindex, nofollow">
}
}
@section Styles { @section Styles {
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<style> <style>

View File

@ -21,10 +21,23 @@
</PackageReference> </PackageReference>
<PackageReference Include="Moq" Version="4.20.70" /> <PackageReference Include="Moq" Version="4.20.70" />
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="8.0.4" />
<PackageReference Include="PuppeteerSharp" Version="15.0.1" />
<PackageReference Include="MongoDB.Driver" Version="2.24.0" />
<PackageReference Include="Stripe.net" Version="43.22.0" />
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration" Version="8.0.0" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="8.0.0" />
<PackageReference Include="FluentAssertions" Version="6.12.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\src\BCards.Web\BCards.Web.csproj" /> <ProjectReference Include="..\..\src\BCards.Web\BCards.Web.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="appsettings.Testing.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>

View File

@ -0,0 +1,95 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using MongoDB.Driver;
using BCards.Web.Configuration;
namespace BCards.Tests.Fixtures;
public class BCardsWebApplicationFactory<TStartup> : WebApplicationFactory<TStartup> where TStartup : class
{
public IMongoDatabase TestDatabase { get; private set; } = null!;
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");
config.AddEnvironmentVariables();
});
builder.ConfigureServices(services =>
{
// Remove the existing MongoDB registration
var mongoDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IMongoDatabase));
if (mongoDescriptor != null)
{
services.Remove(mongoDescriptor);
}
var mongoClientDescriptor = services.SingleOrDefault(
d => d.ServiceType == typeof(IMongoClient));
if (mongoClientDescriptor != null)
{
services.Remove(mongoClientDescriptor);
}
// Add test MongoDB client and database
services.AddSingleton<IMongoClient>(serviceProvider =>
{
var testConnectionString = "mongodb://localhost:27017";
return new MongoClient(testConnectionString);
});
services.AddScoped(serviceProvider =>
{
var client = serviceProvider.GetRequiredService<IMongoClient>();
var databaseName = $"bcards_test_{Guid.NewGuid():N}";
TestDatabase = client.GetDatabase(databaseName);
return TestDatabase;
});
// Configure test Stripe settings
services.Configure<StripeSettings>(options =>
{
options.PublishableKey = "pk_test_fake_key_for_testing";
options.SecretKey = "sk_test_fake_key_for_testing";
options.WebhookSecret = "whsec_fake_webhook_secret_for_testing";
});
});
builder.UseEnvironment("Testing");
// Suppress logs during testing to reduce noise
builder.ConfigureLogging(logging =>
{
logging.ClearProviders();
logging.AddConsole();
logging.SetMinimumLevel(LogLevel.Warning);
});
}
protected override void Dispose(bool disposing)
{
if (disposing && TestDatabase != null)
{
// Clean up test database
try
{
var client = TestDatabase.Client;
client.DropDatabase(TestDatabase.DatabaseNamespace.DatabaseName);
}
catch (Exception)
{
// Ignore cleanup errors
}
}
base.Dispose(disposing);
}
}

View File

@ -0,0 +1,142 @@
using MongoDB.Driver;
using BCards.Web.Models;
using BCards.Web.Repositories;
namespace BCards.Tests.Fixtures;
public class DatabaseFixture : IDisposable
{
public IMongoDatabase Database { get; }
public IUserRepository UserRepository { get; }
public IUserPageRepository UserPageRepository { get; }
public ICategoryRepository CategoryRepository { get; }
public DatabaseFixture(IMongoDatabase database)
{
Database = database;
UserRepository = new UserRepository(database);
UserPageRepository = new UserPageRepository(database);
CategoryRepository = new CategoryRepository(database);
InitializeTestData().Wait();
}
private async Task InitializeTestData()
{
// Clear any existing data
await Database.DropCollectionAsync("users");
await Database.DropCollectionAsync("userpages");
await Database.DropCollectionAsync("categories");
// Initialize test categories
var categories = new List<Category>
{
new Category { Id = "tech", Name = "Tecnologia", Description = "Tecnologia e inovação" },
new Category { Id = "business", Name = "Negócios", Description = "Empresas e negócios" },
new Category { Id = "personal", Name = "Pessoal", Description = "Páginas pessoais" }
};
await CategoryRepository.CreateManyAsync(categories);
}
public async Task<User> CreateTestUser(PlanType planType = PlanType.Trial, string? email = null)
{
var user = new User
{
Id = Guid.NewGuid().ToString(),
Email = email ?? $"test-{Guid.NewGuid():N}@example.com",
Name = "Test User",
CurrentPlan = planType.ToString(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
IsActive = true
};
await UserRepository.CreateAsync(user);
return user;
}
public async Task<UserPage> CreateTestUserPage(string userId, string category = "tech", int linkCount = 1, int productLinkCount = 0)
{
var userPage = new UserPage
{
Id = Guid.NewGuid().ToString(),
UserId = userId,
DisplayName = "Test Page",
Category = category,
Slug = $"test-page-{Guid.NewGuid():N}",
Bio = "Test page description",
Status = BCards.Web.ViewModels.PageStatus.Active,
Links = new List<LinkItem>(),
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow
};
// Add normal links
for (int i = 0; i < linkCount; i++)
{
userPage.Links.Add(new LinkItem
{
Title = $"Test Link {i + 1}",
Url = $"https://example.com/link{i + 1}",
Description = $"Test link {i + 1} description",
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 = $"Test product {i + 1} description",
Icon = "fas fa-shopping-cart",
IsActive = true,
Order = linkCount + i,
Type = LinkType.Product,
ProductTitle = $"Product {i + 1}",
ProductPrice = "R$ 99,90",
ProductDescription = $"Amazing product {i + 1}"
});
}
await UserPageRepository.CreateAsync(userPage);
return userPage;
}
public async Task CleanDatabase()
{
var collections = new[] { "users", "userpages", "categories", "livepages" };
foreach (var collection in collections)
{
try
{
await Database.DropCollectionAsync(collection);
}
catch (Exception)
{
// Ignore errors when collection doesn't exist
}
}
await InitializeTestData();
}
public void Dispose()
{
try
{
Database.Client.DropDatabase(Database.DatabaseNamespace.DatabaseName);
}
catch (Exception)
{
// Ignore cleanup errors
}
}
}

View File

@ -0,0 +1,39 @@
{
"ConnectionStrings": {
"DefaultConnection": "mongodb://localhost:27017/bcards_test"
},
"MongoDb": {
"ConnectionString": "mongodb://localhost:27017",
"DatabaseName": "bcards_test"
},
"Stripe": {
"PublishableKey": "pk_test_fake_key_for_testing",
"SecretKey": "sk_test_fake_key_for_testing",
"WebhookSecret": "whsec_fake_webhook_secret_for_testing"
},
"Authentication": {
"Google": {
"ClientId": "fake-google-client-id",
"ClientSecret": "fake-google-client-secret"
},
"Microsoft": {
"ClientId": "fake-microsoft-client-id",
"ClientSecret": "fake-microsoft-client-secret"
}
},
"SendGrid": {
"ApiKey": "fake-sendgrid-api-key"
},
"Moderation": {
"RequireApproval": false,
"AuthKey": "test-moderation-key",
"MaxPendingPages": 1000
},
"Logging": {
"LogLevel": {
"Default": "Warning",
"Microsoft.AspNetCore": "Warning",
"BCards": "Information"
}
}
}