feat: Tela de gestão da assinatura
This commit is contained in:
parent
06d2c110d0
commit
c933510348
@ -17,7 +17,8 @@
|
||||
"Bash(sudo rm:*)",
|
||||
"Bash(rm:*)",
|
||||
"Bash(curl:*)",
|
||||
"Bash(docker-compose up:*)"
|
||||
"Bash(docker-compose up:*)",
|
||||
"Bash(dotnet build:*)"
|
||||
]
|
||||
},
|
||||
"enableAllProjectMcpServers": false
|
||||
|
||||
10
BCards.sln
10
BCards.sln
@ -4,7 +4,7 @@ VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Web", "src\BCards.Web\BCards.Web.csproj", "{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.Tests", "tests\BCards.Tests\BCards.Tests.csproj", "{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BCards.IntegrationTests", "src\BCards.IntegrationTests\BCards.IntegrationTests.csproj", "{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
@ -16,10 +16,10 @@ Global
|
||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2E8F4B5C-9B3A-4F8E-8C7D-1A2B3C4D5E6F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5E64FFFD-4D6F-4C5A-A4BC-AF93A1C603A3}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8F9E4C7D-2A3B-4E5F-9C8D-1B2A3E4F5C6D}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
|
||||
45
src/BCards.IntegrationTests/BCards.IntegrationTests.csproj
Normal file
45
src/BCards.IntegrationTests/BCards.IntegrationTests.csproj
Normal 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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
182
src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs
Normal file
182
src/BCards.IntegrationTests/Fixtures/MongoDbTestFixture.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
92
src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs
Normal file
92
src/BCards.IntegrationTests/Helpers/AuthenticationHelper.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
195
src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs
Normal file
195
src/BCards.IntegrationTests/Helpers/PuppeteerTestHelper.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
157
src/BCards.IntegrationTests/README.md
Normal file
157
src/BCards.IntegrationTests/README.md
Normal 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
|
||||
204
src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs
Normal file
204
src/BCards.IntegrationTests/Tests/ModerationWorkflowTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
238
src/BCards.IntegrationTests/Tests/PageCreationTests.cs
Normal file
238
src/BCards.IntegrationTests/Tests/PageCreationTests.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
240
src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs
Normal file
240
src/BCards.IntegrationTests/Tests/PreviewTokenTests.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
43
src/BCards.IntegrationTests/appsettings.Testing.json
Normal file
43
src/BCards.IntegrationTests/appsettings.Testing.json
Normal 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"
|
||||
}
|
||||
@ -18,6 +18,7 @@ public class AdminController : Controller
|
||||
private readonly IThemeService _themeService;
|
||||
private readonly IModerationService _moderationService;
|
||||
private readonly IEmailService _emailService;
|
||||
private readonly ILivePageService _livePageService;
|
||||
private readonly ILogger<AdminController> _logger;
|
||||
|
||||
public AdminController(
|
||||
@ -27,6 +28,7 @@ public class AdminController : Controller
|
||||
IThemeService themeService,
|
||||
IModerationService moderationService,
|
||||
IEmailService emailService,
|
||||
ILivePageService livePageService,
|
||||
ILogger<AdminController> logger)
|
||||
{
|
||||
_authService = authService;
|
||||
@ -35,6 +37,7 @@ public class AdminController : Controller
|
||||
_themeService = themeService;
|
||||
_moderationService = moderationService;
|
||||
_emailService = emailService;
|
||||
_livePageService = livePageService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@ -248,7 +251,7 @@ public class AdminController : Controller
|
||||
UpdateUserPageFromModel(existingPage, model);
|
||||
|
||||
// Set status to PendingModeration for updates
|
||||
existingPage.Status = ViewModels.PageStatus.PendingModeration;
|
||||
existingPage.Status = ViewModels.PageStatus.Creating;
|
||||
existingPage.ModerationAttempts = existingPage.ModerationAttempts;
|
||||
|
||||
await _userPageService.UpdatePageAsync(existingPage);
|
||||
@ -493,10 +496,11 @@ public class AdminController : Controller
|
||||
public async Task<IActionResult> GenerateSlug(string category, string name)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(category) || string.IsNullOrWhiteSpace(name))
|
||||
return Json(new { slug = "" });
|
||||
return Json(new { slug = "", category = "" });
|
||||
|
||||
var slug = await _userPageService.GenerateSlugAsync(category, name);
|
||||
return Json(new { slug });
|
||||
var categorySlug = SlugHelper.CreateCategorySlug(category).ToLower();
|
||||
return Json(new { slug = slug, category = categorySlug });
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
@ -884,4 +888,74 @@ public class AdminController : Controller
|
||||
return Json(new { success = false, message = "Erro interno. Tente novamente." });
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
[Route("MigrateToLivePages")]
|
||||
public async Task<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}"
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/BCards.Web/Controllers/LivePageController.cs
Normal file
93
src/BCards.Web/Controllers/LivePageController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -114,7 +114,7 @@ public class ModerationController : Controller
|
||||
user.Email,
|
||||
user.Name,
|
||||
page.DisplayName,
|
||||
"approved");
|
||||
PageStatus.Active.ToString());
|
||||
}
|
||||
|
||||
TempData["Success"] = $"Página '{page.DisplayName}' aprovada com sucesso!";
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
using BCards.Web.Models;
|
||||
using BCards.Web.Repositories;
|
||||
using BCards.Web.Services;
|
||||
using BCards.Web.ViewModels;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Identity;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BCards.Web.Controllers;
|
||||
@ -9,11 +13,15 @@ public class PaymentController : Controller
|
||||
{
|
||||
private readonly IPaymentService _paymentService;
|
||||
private readonly IAuthService _authService;
|
||||
private readonly IUserRepository _userService;
|
||||
private readonly ISubscriptionRepository _subscriptionRepository;
|
||||
|
||||
public PaymentController(IPaymentService paymentService, IAuthService authService)
|
||||
public PaymentController(IPaymentService paymentService, IAuthService authService, IUserRepository userService, ISubscriptionRepository subscriptionRepository)
|
||||
{
|
||||
_paymentService = paymentService;
|
||||
_authService = authService;
|
||||
_userService = userService;
|
||||
_subscriptionRepository = subscriptionRepository;
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -26,6 +34,8 @@ public class PaymentController : Controller
|
||||
var successUrl = Url.Action("Success", "Payment", null, Request.Scheme);
|
||||
var cancelUrl = Url.Action("Cancel", "Payment", null, Request.Scheme);
|
||||
|
||||
TempData[$"PlanType|{user.Id}"] = planType;
|
||||
|
||||
try
|
||||
{
|
||||
var checkoutUrl = await _paymentService.CreateCheckoutSessionAsync(
|
||||
@ -43,11 +53,30 @@ 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);
|
||||
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()
|
||||
{
|
||||
@ -87,7 +116,36 @@ public class PaymentController : Controller
|
||||
if (user == null)
|
||||
return RedirectToAction("Login", "Auth");
|
||||
|
||||
return View(user);
|
||||
try
|
||||
{
|
||||
var viewModel = new ManageSubscriptionViewModel
|
||||
{
|
||||
User = user,
|
||||
StripeSubscription = await _paymentService.GetSubscriptionDetailsAsync(user.Id),
|
||||
PaymentHistory = await _paymentService.GetPaymentHistoryAsync(user.Id),
|
||||
AvailablePlans = GetAvailablePlans(user.CurrentPlan)
|
||||
};
|
||||
|
||||
// Pegar assinatura local se existir
|
||||
if (!string.IsNullOrEmpty(user.StripeCustomerId))
|
||||
{
|
||||
// Aqui você poderia buscar a subscription local se necessário
|
||||
// viewModel.LocalSubscription = await _subscriptionRepository.GetByUserIdAsync(user.Id);
|
||||
}
|
||||
|
||||
return View(viewModel);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var errorViewModel = new ManageSubscriptionViewModel
|
||||
{
|
||||
User = user,
|
||||
ErrorMessage = $"Erro ao carregar dados da assinatura: {ex.Message}",
|
||||
AvailablePlans = GetAvailablePlans(user.CurrentPlan)
|
||||
};
|
||||
|
||||
return View(errorViewModel);
|
||||
}
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
@ -105,4 +163,111 @@ public class PaymentController : Controller
|
||||
|
||||
return RedirectToAction("ManageSubscription");
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<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;
|
||||
}
|
||||
}
|
||||
@ -8,11 +8,16 @@ namespace BCards.Web.Controllers;
|
||||
public class SitemapController : Controller
|
||||
{
|
||||
private readonly IUserPageService _userPageService;
|
||||
private readonly ILivePageService _livePageService;
|
||||
private readonly ILogger<SitemapController> _logger;
|
||||
|
||||
public SitemapController(IUserPageService userPageService, ILogger<SitemapController> logger)
|
||||
public SitemapController(
|
||||
IUserPageService userPageService,
|
||||
ILivePageService livePageService,
|
||||
ILogger<SitemapController> logger)
|
||||
{
|
||||
_userPageService = userPageService;
|
||||
_livePageService = livePageService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@ -22,7 +27,8 @@ public class SitemapController : Controller
|
||||
{
|
||||
try
|
||||
{
|
||||
var activePages = await _userPageService.GetActivePagesAsync();
|
||||
// 🔥 NOVA FUNCIONALIDADE: Usar LivePages em vez de UserPages
|
||||
var livePages = await _livePageService.GetAllActiveAsync();
|
||||
|
||||
var sitemap = new XDocument(
|
||||
new XDeclaration("1.0", "utf-8", "yes"),
|
||||
@ -43,11 +49,11 @@ public class SitemapController : Controller
|
||||
new XElement("priority", "0.9")
|
||||
),
|
||||
|
||||
// Add user pages (only active ones)
|
||||
activePages.Select(page =>
|
||||
// Add live pages (SEO-optimized URLs only)
|
||||
livePages.Select(page =>
|
||||
new XElement("url",
|
||||
new XElement("loc", $"{Request.Scheme}://{Request.Host}/page/{page.Category}/{page.Slug}"),
|
||||
new XElement("lastmod", page.UpdatedAt.ToString("yyyy-MM-dd")),
|
||||
new XElement("lastmod", page.LastSyncAt.ToString("yyyy-MM-dd")),
|
||||
new XElement("changefreq", "weekly"),
|
||||
new XElement("priority", "0.8")
|
||||
)
|
||||
@ -55,7 +61,7 @@ public class SitemapController : Controller
|
||||
)
|
||||
);
|
||||
|
||||
_logger.LogInformation($"Generated sitemap with {activePages.Count} user pages");
|
||||
_logger.LogInformation($"Generated sitemap with {livePages.Count} live pages");
|
||||
|
||||
return Content(sitemap.ToString(), "application/xml", Encoding.UTF8);
|
||||
}
|
||||
|
||||
@ -1,4 +1,6 @@
|
||||
using BCards.Web.Models;
|
||||
using BCards.Web.Services;
|
||||
using BCards.Web.Utils;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace BCards.Web.Controllers;
|
||||
@ -128,4 +130,5 @@ public class UserPageController : Controller
|
||||
|
||||
return View("Display", userPage);
|
||||
}
|
||||
|
||||
}
|
||||
26
src/BCards.Web/Models/IPageDisplay.cs
Normal file
26
src/BCards.Web/Models/IPageDisplay.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
75
src/BCards.Web/Models/LivePage.cs
Normal file
75
src/BCards.Web/Models/LivePage.cs
Normal 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; }
|
||||
}
|
||||
@ -4,7 +4,7 @@ using BCards.Web.ViewModels;
|
||||
|
||||
namespace BCards.Web.Models;
|
||||
|
||||
public class UserPage
|
||||
public class UserPage : IPageDisplay
|
||||
{
|
||||
[BsonId]
|
||||
[BsonRepresentation(BsonType.ObjectId)]
|
||||
|
||||
@ -126,6 +126,10 @@ builder.Services.AddScoped<IOpenGraphService, OpenGraphService>();
|
||||
builder.Services.AddScoped<IModerationService, ModerationService>();
|
||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||
|
||||
// 🔥 NOVO: LivePage Services
|
||||
builder.Services.AddScoped<ILivePageRepository, LivePageRepository>();
|
||||
builder.Services.AddScoped<ILivePageService, LivePageService>();
|
||||
|
||||
// Add HttpClient for OpenGraphService
|
||||
builder.Services.AddHttpClient<OpenGraphService>();
|
||||
|
||||
@ -205,10 +209,11 @@ app.MapControllerRoute(
|
||||
// defaults: new { controller = "UserPage", action = "Display" },
|
||||
// constraints: new { category = @"^[a-zA-Z-]+$", slug = @"^[a-z0-9-]+$" });
|
||||
|
||||
// 🔥 NOVA ROTA: LivePageController para páginas otimizadas de SEO
|
||||
app.MapControllerRoute(
|
||||
name: "userpage",
|
||||
name: "livepage",
|
||||
pattern: "page/{category}/{slug}",
|
||||
defaults: new { controller = "UserPage", action = "Display" },
|
||||
defaults: new { controller = "LivePage", action = "Display" },
|
||||
constraints: new
|
||||
{
|
||||
category = @"^[a-zA-Z0-9\-\u00C0-\u017F]+$", // ← Aceita acentos
|
||||
@ -251,3 +256,6 @@ using (var scope = app.Services.CreateScope())
|
||||
}
|
||||
|
||||
app.Run();
|
||||
|
||||
// Make Program accessible for integration tests
|
||||
public partial class Program { }
|
||||
17
src/BCards.Web/Repositories/ILivePageRepository.cs
Normal file
17
src/BCards.Web/Repositories/ILivePageRepository.cs
Normal 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);
|
||||
}
|
||||
122
src/BCards.Web/Repositories/LivePageRepository.cs
Normal file
122
src/BCards.Web/Repositories/LivePageRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
13
src/BCards.Web/Services/ILivePageService.cs
Normal file
13
src/BCards.Web/Services/ILivePageService.cs
Normal 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);
|
||||
}
|
||||
@ -12,4 +12,9 @@ public interface IPaymentService
|
||||
Task<bool> CancelSubscriptionAsync(string subscriptionId);
|
||||
Task<Stripe.Subscription> UpdateSubscriptionAsync(string subscriptionId, string newPriceId);
|
||||
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);
|
||||
}
|
||||
113
src/BCards.Web/Services/LivePageService.cs
Normal file
113
src/BCards.Web/Services/LivePageService.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -9,15 +9,18 @@ public class ModerationService : IModerationService
|
||||
{
|
||||
private readonly IUserPageRepository _userPageRepository;
|
||||
private readonly IUserRepository _userRepository;
|
||||
private readonly ILivePageService _livePageService;
|
||||
private readonly ILogger<ModerationService> _logger;
|
||||
|
||||
public ModerationService(
|
||||
IUserPageRepository userPageRepository,
|
||||
IUserRepository userRepository,
|
||||
ILivePageService livePageService,
|
||||
ILogger<ModerationService> logger)
|
||||
{
|
||||
_userPageRepository = userPageRepository;
|
||||
_userRepository = userRepository;
|
||||
_livePageService = livePageService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@ -105,6 +108,18 @@ public class ModerationService : IModerationService
|
||||
|
||||
await _userPageRepository.UpdateAsync(pageId, update);
|
||||
_logger.LogInformation("Page {PageId} approved by moderator {ModeratorId}", pageId, moderatorId);
|
||||
|
||||
// 🔥 NOVA FUNCIONALIDADE: Sincronizar para LivePage
|
||||
try
|
||||
{
|
||||
await _livePageService.SyncFromUserPageAsync(pageId);
|
||||
_logger.LogInformation("Page {PageId} synced to LivePages successfully", pageId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to sync page {PageId} to LivePages. Approval completed but sync failed.", pageId);
|
||||
// Não falhar a aprovação se sync falhar
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RejectPageAsync(string pageId, string moderatorId, string reason, List<string> issues)
|
||||
@ -139,6 +154,17 @@ public class ModerationService : IModerationService
|
||||
await _userPageRepository.UpdateAsync(pageId, update);
|
||||
_logger.LogInformation("Page {PageId} rejected by moderator {ModeratorId}. Reason: {Reason}",
|
||||
pageId, moderatorId, reason);
|
||||
|
||||
// Remover da LivePages se existir
|
||||
try
|
||||
{
|
||||
await _livePageService.DeleteByOriginalPageIdAsync(pageId);
|
||||
_logger.LogInformation("LivePage removed for rejected UserPage {PageId}", pageId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to remove LivePage for UserPage {PageId}", pageId);
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<bool> CanUserCreatePageAsync(string userId)
|
||||
|
||||
@ -4,6 +4,7 @@ using BCards.Web.Repositories;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Stripe;
|
||||
using Stripe.Checkout;
|
||||
using Stripe.BillingPortal;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
@ -41,7 +42,7 @@ public class PaymentService : IPaymentService
|
||||
|
||||
var customer = await CreateOrGetCustomerAsync(userId, user.Email, user.Name);
|
||||
|
||||
var options = new SessionCreateOptions
|
||||
var options = new Stripe.Checkout.SessionCreateOptions
|
||||
{
|
||||
PaymentMethodTypes = new List<string> { "card" },
|
||||
Mode = "subscription",
|
||||
@ -63,7 +64,7 @@ public class PaymentService : IPaymentService
|
||||
}
|
||||
};
|
||||
|
||||
var service = new SessionService();
|
||||
var service = new Stripe.Checkout.SessionService();
|
||||
var session = await service.CreateAsync(options);
|
||||
|
||||
return session.Url;
|
||||
@ -119,7 +120,7 @@ public class PaymentService : IPaymentService
|
||||
switch (stripeEvent.Type)
|
||||
{
|
||||
case Events.CheckoutSessionCompleted:
|
||||
var session = stripeEvent.Data.Object as Session;
|
||||
var session = stripeEvent.Data.Object as Stripe.Checkout.Session;
|
||||
await HandleCheckoutSessionCompletedAsync(session!);
|
||||
break;
|
||||
|
||||
@ -246,7 +247,7 @@ public class PaymentService : IPaymentService
|
||||
return Task.FromResult(limitations);
|
||||
}
|
||||
|
||||
private async Task HandleCheckoutSessionCompletedAsync(Session session)
|
||||
private async Task HandleCheckoutSessionCompletedAsync(Stripe.Checkout.Session session)
|
||||
{
|
||||
var userId = session.Metadata["user_id"];
|
||||
var planType = session.Metadata["plan_type"];
|
||||
@ -319,4 +320,70 @@ public class PaymentService : IPaymentService
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -3,6 +3,7 @@ using BCards.Web.Repositories;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using BCards.Web.Utils;
|
||||
|
||||
namespace BCards.Web.Services;
|
||||
|
||||
@ -76,7 +77,7 @@ public class UserPageService : IUserPageService
|
||||
|
||||
public async Task<string> GenerateSlugAsync(string category, string name)
|
||||
{
|
||||
var slug = GenerateSlug(name);
|
||||
var slug = SlugHelper.CreateSlug(GenerateSlug(name));
|
||||
var originalSlug = slug;
|
||||
var counter = 1;
|
||||
|
||||
|
||||
83
src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs
Normal file
83
src/BCards.Web/ViewModels/ManageSubscriptionViewModel.cs
Normal 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"
|
||||
};
|
||||
}
|
||||
@ -25,7 +25,7 @@
|
||||
<div class="row">
|
||||
@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-body">
|
||||
<h6 class="card-title">
|
||||
@ -37,6 +37,7 @@
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
<input type="hidden" id="displayName_@pageItem.Id" value="@(pageItem.DisplayName)" />
|
||||
</h6>
|
||||
<p class="text-muted small mb-2">@(pageItem.Category)/@(pageItem.Slug)</p>
|
||||
|
||||
@ -92,18 +93,19 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<div class="d-flex gap-1 flex-wrap" data-page-id="@pageItem.Id" data-status="@pageItem.Status">
|
||||
<!-- Botão Editar - sempre presente -->
|
||||
<a href="@Url.Action("ManagePage", new { id = pageItem.Id })"
|
||||
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)
|
||||
<div class="d-flex gap-1 align-items-center" data-page-id="@pageItem.Id" data-status="@pageItem.Status">
|
||||
<!-- Botão Ver - sempre presente quando possível -->
|
||||
@if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Active)
|
||||
{
|
||||
<a href="/page/@pageItem.Category/@pageItem.Slug" target="_blank"
|
||||
class="btn btn-sm btn-success">
|
||||
<i class="fas fa-external-link-alt me-1"></i>Ver
|
||||
</a>
|
||||
}
|
||||
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Creating ||
|
||||
pageItem.Status == BCards.Web.ViewModels.PageStatus.Rejected ||
|
||||
pageItem.Status == BCards.Web.ViewModels.PageStatus.PendingModeration)
|
||||
{
|
||||
<!-- Preview para páginas em desenvolvimento -->
|
||||
<button type="button"
|
||||
class="btn btn-sm btn-outline-info"
|
||||
onclick="openPreview('@pageItem.Id')"
|
||||
@ -111,37 +113,62 @@
|
||||
data-page-slug="@pageItem.Slug">
|
||||
<i class="fas fa-eye me-1"></i>Preview
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Botão Enviar para Moderação -->
|
||||
<!-- 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="btn btn-sm btn-success"
|
||||
class="dropdown-item"
|
||||
onclick="submitForModeration('@pageItem.Id')"
|
||||
data-page-name="@pageItem.DisplayName">
|
||||
<i class="fas fa-paper-plane me-1"></i>Enviar
|
||||
<i class="fas fa-paper-plane me-2"></i>Enviar para Moderação
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
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
|
||||
<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>
|
||||
}
|
||||
else if (pageItem.Status == BCards.Web.ViewModels.PageStatus.Active)
|
||||
{
|
||||
<!-- Ver página ativa -->
|
||||
<a href="/page/@pageItem.Category/@pageItem.Slug" target="_blank"
|
||||
class="btn btn-sm btn-outline-success">
|
||||
<i class="fas fa-external-link-alt me-1"></i>Ver
|
||||
</a>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer bg-transparent">
|
||||
@ -176,12 +203,35 @@
|
||||
</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 -->
|
||||
@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-body d-flex align-items-center justify-content-center">
|
||||
<div>
|
||||
@ -228,6 +278,7 @@
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
@using BCards.Web.Utils
|
||||
@model BCards.Web.ViewModels.ManagePageViewModel
|
||||
@{
|
||||
ViewData["Title"] = Model.IsNewPage ? "Criar Página" : "Editar Página";
|
||||
@ -90,7 +91,7 @@
|
||||
<label for="slugPreview" class="form-label">URL da Página</label>
|
||||
<div class="input-group">
|
||||
<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>
|
||||
<input type="text" class="form-control" id="slugPreview" value="@Model.Slug" readonly>
|
||||
<input asp-for="Slug" type="hidden">
|
||||
@ -860,7 +861,7 @@
|
||||
.done(function(data) {
|
||||
$('#Slug').val(data.slug);
|
||||
$('#slugPreview').val(data.slug);
|
||||
$('#categorySlug').text(category);
|
||||
$('#categorySlug').text(data.category);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
291
src/BCards.Web/Views/Payment/ManageSubscription.cshtml
Normal file
291
src/BCards.Web/Views/Payment/ManageSubscription.cshtml
Normal 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>
|
||||
}
|
||||
@ -31,6 +31,8 @@
|
||||
<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="~/lib/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="~/css/site.css" asp-append-version="true" />
|
||||
|
||||
@ -41,6 +41,8 @@
|
||||
</script>
|
||||
}
|
||||
|
||||
@await RenderSectionAsync("Head", required: false)
|
||||
|
||||
<link rel="stylesheet" href="~/lib/bootstrap/css/bootstrap.min.css" />
|
||||
|
||||
<link rel="stylesheet" href="~/css/userpage.css" asp-append-version="true" />
|
||||
|
||||
@ -1,13 +1,33 @@
|
||||
@model BCards.Web.Models.UserPage
|
||||
@model BCards.Web.Models.IPageDisplay
|
||||
@{
|
||||
var seo = ViewBag.SeoSettings as BCards.Web.Models.SeoSettings;
|
||||
var category = ViewBag.Category as BCards.Web.Models.Category;
|
||||
var isPreview = ViewBag.IsPreview as bool? ?? false;
|
||||
var isLivePage = ViewBag.IsLivePage as bool? ?? false;
|
||||
|
||||
ViewData["Title"] = seo?.Title ?? $"{Model.DisplayName} - {category?.Name}";
|
||||
Layout = isPreview ? "_Layout" : "_UserPageLayout";
|
||||
}
|
||||
|
||||
@section Head {
|
||||
@if (isPreview)
|
||||
{
|
||||
<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 {
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
|
||||
@ -21,10 +21,23 @@
|
||||
</PackageReference>
|
||||
<PackageReference Include="Moq" Version="4.20.70" />
|
||||
<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>
|
||||
<ProjectReference Include="..\..\src\BCards.Web\BCards.Web.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.Testing.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
95
tests/BCards.Tests/Fixtures/BCardsWebApplicationFactory.cs
Normal file
95
tests/BCards.Tests/Fixtures/BCardsWebApplicationFactory.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
142
tests/BCards.Tests/Fixtures/DatabaseFixture.cs
Normal file
142
tests/BCards.Tests/Fixtures/DatabaseFixture.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
39
tests/BCards.Tests/appsettings.Testing.json
Normal file
39
tests/BCards.Tests/appsettings.Testing.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user