chore: correct namespaces and hiearchy

This commit is contained in:
2026-01-31 02:16:32 -05:00
parent 56d393e127
commit 19e2c22111
136 changed files with 1366 additions and 1404 deletions

482
src/TrackApi/TrackQrApi.Tests/.gitignore vendored Normal file
View File

@@ -0,0 +1,482 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from `dotnet new gitignore`
# dotenv files
.env
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET
project.lock.json
project.fragment.lock.json
artifacts/
# Tye
.tye/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
# but not Directory.Build.rsp, as it configures directory-level build defaults
!Directory.Build.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.tlog
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio 6 auto-generated project file (contains which files were open etc.)
*.vbp
# Visual Studio 6 workspace and project file (working project files containing files to include in project)
*.dsw
*.dsp
# Visual Studio 6 technical files
*.ncb
*.aps
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# Visual Studio History (VSHistory) files
.vshistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
# VS Code files for those working on multiple tools
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.code-workspace
# Local History for Visual Studio Code
.history/
# Windows Installer files from build outputs
*.cab
*.msi
*.msix
*.msm
*.msp
# JetBrains Rider
*.sln.iml
.idea/
##
## Visual studio for Mac
##
# globs
Makefile.in
*.userprefs
*.usertasks
config.make
config.status
aclocal.m4
install-sh
autom4te.cache/
*.tar.gz
tarballs/
test-results/
# content below from: https://github.com/github/gitignore/blob/main/Global/macOS.gitignore
# General
.DS_Store
.AppleDouble
.LSOverride
# Icon must end with two \r
Icon
# Thumbnails
._*
# Files that might appear in the root of a volume
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
# Directories potentially created on remote AFP share
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
# content below from: https://github.com/github/gitignore/blob/main/Global/Windows.gitignore
# Windows thumbnail cache files
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
# Dump file
*.stackdump
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows Installer files
*.cab
*.msi
*.msix
*.msm
*.msp
# Windows shortcuts
*.lnk
# Vim temporary swap files
*.swp

View File

@@ -0,0 +1,215 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using TrackQrApi.Features.Analytics.Common;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace TrackQrApi.Tests;
public class AnalyticsEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string slug)
{
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = destinationUrl,
Slug = slug
});
return (await createResponse.Content.ReadFromJsonAsync<LinkResponse>())!;
}
[Fact]
public async Task WorkspaceAnalytics_ReturnsEmptyForNewWorkspace()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("analytics-empty@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<WorkspaceAnalyticsResponse>();
result.Should().NotBeNull();
result!.Summary.TotalClicks.Should().Be(0);
result.Summary.TotalScans.Should().Be(0);
result.Summary.UniqueVisitors.Should().Be(0);
}
[Fact]
public async Task WorkspaceAnalytics_ReturnsValidResponse()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("analytics-clicks@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "https://example.com", "analytics-clicks-link");
// Generate some clicks (event tracking is async/fire-and-forget)
await _noRedirectClient.GetAsync($"/{link.Slug}");
// Act - Verify endpoint returns valid response structure
var response = await _client.GetAsync($"/workspaces/{workspaceId}/analytics");
// Assert - Focus on response structure, not click counts (tested separately)
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<WorkspaceAnalyticsResponse>();
result.Should().NotBeNull();
result!.Summary.Should().NotBeNull();
result.TimeSeries.Should().NotBeNull();
result.TopLinks.Should().NotBeNull();
result.DeviceBreakdown.Should().NotBeNull();
result.ReferrerBreakdown.Should().NotBeNull();
}
[Fact]
public async Task WorkspaceAnalytics_WithPeriodFilter_FiltersEvents()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("analytics-period@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/analytics?period=24h");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
}
[Fact]
public async Task WorkspaceAnalytics_Unauthorized_Returns401()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("analytics-unauth@example.com");
_client.DefaultRequestHeaders.Authorization = null;
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task WorkspaceAnalytics_OtherUsersWorkspace_Returns404()
{
// Arrange
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("analytics-user1@example.com");
var (token2, _) = await SetupAuthAndWorkspaceAsync("analytics-user2@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId1}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task LinkAnalytics_ReturnsEmptyForNewLink()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("link-analytics-empty@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "https://example.com", "link-analytics-empty");
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links/{link.Id}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkAnalyticsResponse>();
result.Should().NotBeNull();
result!.LinkId.Should().Be(link.Id);
result.Slug.Should().Be(link.Slug);
result.Summary.TotalClicks.Should().Be(0);
}
[Fact]
public async Task LinkAnalytics_ReturnsValidResponse()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("link-analytics-clicks@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "https://example.com", "link-analytics-clicks");
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links/{link.Id}/analytics");
// Assert - Focus on response structure
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkAnalyticsResponse>();
result.Should().NotBeNull();
result!.LinkId.Should().Be(link.Id);
result.Slug.Should().Be(link.Slug);
result.Summary.Should().NotBeNull();
result.TimeSeries.Should().NotBeNull();
result.DeviceBreakdown.Should().NotBeNull();
result.ReferrerBreakdown.Should().NotBeNull();
}
[Fact]
public async Task LinkAnalytics_InvalidLink_Returns404()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("link-analytics-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links/{Guid.NewGuid()}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task LinkAnalytics_OtherUsersLink_Returns404()
{
// Arrange
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("link-analytics-user1@example.com");
var link = await CreateLinkAsync(workspaceId1, "https://example.com", "link-analytics-user1");
var (token2, _) = await SetupAuthAndWorkspaceAsync("link-analytics-user2@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId1}/links/{link.Id}/analytics");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}

View File

@@ -0,0 +1,91 @@
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Mvc.Testing;
using Microsoft.AspNetCore.TestHost;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Testcontainers.PostgreSql;
using TrackQrApi.Data;
using TrackQrApi.Models;
namespace TrackQrApi.Tests;
public sealed class ApiWebApplicationFactory : WebApplicationFactory<Program>, IAsyncLifetime
{
private readonly PostgreSqlContainer _postgres = new PostgreSqlBuilder("postgres:latest")
.Build();
private bool _containerStarted;
public async Task InitializeAsync()
{
// Ensure container is started (might already be started from ConfigureWebHost)
EnsureContainerStarted();
// Run migrations
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
await db.Database.MigrateAsync();
}
public new async Task DisposeAsync()
{
await _postgres.DisposeAsync();
await base.DisposeAsync();
}
private void EnsureContainerStarted()
{
if (!_containerStarted)
{
_postgres.StartAsync().GetAwaiter().GetResult();
_containerStarted = true;
}
}
protected override void ConfigureWebHost(IWebHostBuilder builder)
{
// Ensure container is started before we need the connection string
EnsureContainerStarted();
builder.UseEnvironment("Testing");
// Set environment variables for configuration (these take precedence)
Environment.SetEnvironmentVariable("Jwt__Secret", "test-secret-key-min-32-characters-long-for-hmac256!");
Environment.SetEnvironmentVariable("Jwt__Issuer", "TrakQR");
Environment.SetEnvironmentVariable("Jwt__Audience", "TrakQR");
Environment.SetEnvironmentVariable("Jwt__ExpirationMinutes", "60");
Environment.SetEnvironmentVariable("Email__Provider", "console");
Environment.SetEnvironmentVariable("Stripe__SecretKey", "sk_test_fake_key");
Environment.SetEnvironmentVariable("Stripe__WebhookSecret", "whsec_fake_secret");
Environment.SetEnvironmentVariable("Stripe__ProPriceId", "price_test_pro");
Environment.SetEnvironmentVariable("Stripe__BusinessPriceId", "price_test_business");
Environment.SetEnvironmentVariable("ConnectionStrings__PostgresConnection", _postgres.GetConnectionString());
builder.ConfigureTestServices(services =>
{
// Remove existing DbContext registration
var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DbContextOptions<AppDbContext>));
if (descriptor != null) services.Remove(descriptor);
// Add DbContext with Testcontainers connection string
services.AddDbContext<AppDbContext>(options =>
options.UseNpgsql(_postgres.GetConnectionString()));
});
}
/// <summary>
/// Upgrades a workspace to Pro plan for testing features that require paid plans.
/// </summary>
public async Task UpgradeWorkspaceToPro(Guid workspaceId)
{
using var scope = Services.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
var workspace = await db.Workspaces.FindAsync(workspaceId);
if (workspace != null)
{
workspace.Plan = WorkspacePlan.Pro;
await db.SaveChangesAsync();
}
}
}

View File

@@ -0,0 +1,245 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using TrackQrApi.Features.Assets.Common;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace TrackQrApi.Tests;
public class AssetEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = result!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var workspacesResponse = await _client.GetAsync("/workspaces");
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = workspaces!.Workspaces.First().Id;
return (token, workspaceId);
}
private static MultipartFormDataContent CreateImageUpload(string filename, string contentType = "image/png")
{
var content = new MultipartFormDataContent();
// Create a minimal valid PNG (1x1 transparent pixel)
var pngBytes = new byte[]
{
0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, // PNG signature
0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, // IHDR chunk
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
0x08, 0x06, 0x00, 0x00, 0x00, 0x1F, 0x15, 0xC4,
0x89, 0x00, 0x00, 0x00, 0x0A, 0x49, 0x44, 0x41,
0x54, 0x78, 0x9C, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0D, 0x0A, 0x2D, 0xB4, 0x00,
0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE,
0x42, 0x60, 0x82 // IEND chunk
};
var fileContent = new ByteArrayContent(pngBytes);
fileContent.Headers.ContentType = new MediaTypeHeaderValue(contentType);
content.Add(fileContent, "file", filename);
return content;
}
[Fact]
public async Task UploadAsset_WithValidImage_ReturnsCreated()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("upload-asset@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var content = CreateImageUpload("logo.png");
// Act
var response = await _client.PostAsync($"/workspaces/{workspaceId}/assets", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<AssetResponse>();
result.Should().NotBeNull();
result!.Type.Should().Be("Logo");
result.Mime.Should().Be("image/png");
result.Size.Should().BeGreaterThan(0);
result.Url.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task UploadAsset_WithInvalidMimeType_ReturnsBadRequest()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("upload-asset-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var content = new MultipartFormDataContent();
var fileContent = new ByteArrayContent(new byte[] { 0x00, 0x01, 0x02 });
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/pdf");
content.Add(fileContent, "file", "document.pdf");
// Act
var response = await _client.PostAsync($"/workspaces/{workspaceId}/assets", content);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task UploadAsset_WithoutFile_ReturnsError()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("upload-asset-nofile@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var content = new MultipartFormDataContent();
// Act
var response = await _client.PostAsync($"/workspaces/{workspaceId}/assets", content);
// Assert - either 400 or 500 is acceptable for empty multipart form
response.IsSuccessStatusCode.Should().BeFalse();
}
[Fact]
public async Task ListAssets_ReturnsAssetsForWorkspace()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("list-assets@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var content = CreateImageUpload("list-test.png");
await _client.PostAsync($"/workspaces/{workspaceId}/assets", content);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/assets");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AssetListResponse>();
result.Should().NotBeNull();
result!.Assets.Should().HaveCountGreaterThanOrEqualTo(1);
}
[Fact]
public async Task GetAsset_WithValidStorageKey_ReturnsFile()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-asset@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var content = CreateImageUpload("get-test.png");
var uploadResponse = await _client.PostAsync($"/workspaces/{workspaceId}/assets", content);
var uploaded = await uploadResponse.Content.ReadFromJsonAsync<AssetResponse>();
// Extract storage key from URL
var storageKey = uploaded!.Url.Split('/').Last();
// Act - Get asset publicly (no auth needed)
_client.DefaultRequestHeaders.Authorization = null;
var response = await _client.GetAsync($"/assets/{storageKey}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("image/png");
}
[Fact]
public async Task GetAsset_WithInvalidStorageKey_ReturnsNotFound()
{
// Act
var response = await _client.GetAsync($"/assets/{Guid.NewGuid()}.png");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task DeleteAsset_WithValidId_ReturnsSuccess()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("delete-asset@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var content = CreateImageUpload("to-delete.png");
var uploadResponse = await _client.PostAsync($"/workspaces/{workspaceId}/assets", content);
var uploaded = await uploadResponse.Content.ReadFromJsonAsync<AssetResponse>();
// Act
var response = await _client.DeleteAsync($"/workspaces/{workspaceId}/assets/{uploaded!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Verify it's deleted from list
var listResponse = await _client.GetAsync($"/workspaces/{workspaceId}/assets");
var list = await listResponse.Content.ReadFromJsonAsync<AssetListResponse>();
list!.Assets.Should().NotContain(a => a.Id == uploaded.Id);
}
[Fact]
public async Task DeleteAsset_WithInvalidId_ReturnsNotFound()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("delete-asset-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.DeleteAsync($"/workspaces/{workspaceId}/assets/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Asset_CannotAccessOtherUsersWorkspace()
{
// Arrange
var (token1, workspaceId1) = await GetAuthAndWorkspaceAsync("asset-user1@example.com");
var (token2, _) = await GetAuthAndWorkspaceAsync("asset-user2@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
var content = CreateImageUpload("user1-asset.png");
var uploadResponse = await _client.PostAsync($"/workspaces/{workspaceId1}/assets", content);
var uploaded = await uploadResponse.Content.ReadFromJsonAsync<AssetResponse>();
// Act - Try to delete as user2
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/assets/{uploaded!.Id}");
// Assert
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Asset_PublicAccessDoesNotRequireAuth()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("asset-public@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var content = CreateImageUpload("public-test.png");
var uploadResponse = await _client.PostAsync($"/workspaces/{workspaceId}/assets", content);
var uploaded = await uploadResponse.Content.ReadFromJsonAsync<AssetResponse>();
var storageKey = uploaded!.Url.Split('/').Last();
// Act - Access without auth
_client.DefaultRequestHeaders.Authorization = null;
var response = await _client.GetAsync($"/assets/{storageKey}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Headers.CacheControl!.MaxAge.Should().Be(TimeSpan.FromSeconds(31536000));
}
}

View File

@@ -0,0 +1,226 @@
using System.Net;
using System.Net.Http.Json;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
namespace TrackQrApi.Tests;
public class AuthControllerTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
[Fact]
public async Task Register_WithValidCredentials_ReturnsTokenAndUser()
{
// Arrange
var request = new { Email = "newuser@example.com", Password = "password123" };
// Act
var response = await _client.PostAsJsonAsync("/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
result.Should().NotBeNull();
result!.Token.Should().NotBeNullOrEmpty();
result.User.Email.Should().Be("newuser@example.com");
result.User.IsVerified.Should().BeFalse();
result.ExpiresAt.Should().BeAfter(DateTime.UtcNow);
}
[Fact]
public async Task Register_WithDuplicateEmail_ReturnsConflict()
{
// Arrange
var request = new { Email = "duplicate@example.com", Password = "password123" };
// First registration
await _client.PostAsJsonAsync("/auth/register", request);
// Act - try to register again
var response = await _client.PostAsJsonAsync("/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("Email already registered");
}
[Fact]
public async Task Register_WithInvalidEmail_ReturnsBadRequest()
{
// Arrange
var request = new { Email = "not-an-email", Password = "password123" };
// Act
var response = await _client.PostAsJsonAsync("/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Register_WithShortPassword_ReturnsBadRequest()
{
// Arrange
var request = new { Email = "shortpw@example.com", Password = "short" };
// Act
var response = await _client.PostAsJsonAsync("/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task Login_WithValidCredentials_ReturnsToken()
{
// Arrange
var email = "logintest@example.com";
var password = "password123";
await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = password });
// Act
var response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = password });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
result.Should().NotBeNull();
result!.Token.Should().NotBeNullOrEmpty();
result.User.Email.Should().Be(email);
}
[Fact]
public async Task Login_WithWrongPassword_ReturnsUnauthorized()
{
// Arrange
var email = "wrongpw@example.com";
await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "correctpassword" });
// Act
var response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "wrongpassword" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("Invalid email or password");
}
[Fact]
public async Task Login_WithNonExistentEmail_ReturnsUnauthorized()
{
// Arrange
var request = new { Email = "nonexistent@example.com", Password = "password123" };
// Act
var response = await _client.PostAsJsonAsync("/auth/login", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("Invalid email or password");
}
[Fact]
public async Task ForgotPassword_WithAnyEmail_ReturnsSuccessMessage()
{
// Arrange - using a non-existent email to verify we don't leak info
var request = new { Email = "anyone@example.com" };
// Act
var response = await _client.PostAsJsonAsync("/auth/forgot", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("If the email exists, a reset link will be sent");
}
[Fact]
public async Task ForgotPassword_WithExistingEmail_ReturnsSuccessMessage()
{
// Arrange
var email = "forgotpw@example.com";
await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
// Act
var response = await _client.PostAsJsonAsync("/auth/forgot", new { Email = email });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("If the email exists, a reset link will be sent");
}
[Fact]
public async Task ResetPassword_WithInvalidToken_ReturnsBadRequest()
{
// Arrange
var request = new { Token = "some-token", NewPassword = "newpassword123" };
// Act
var response = await _client.PostAsJsonAsync("/auth/reset", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
var result = await response.Content.ReadFromJsonAsync<MessageResponse>();
result.Should().NotBeNull();
result!.Message.Should().Be("Invalid or expired reset token");
}
[Fact]
public async Task Register_CreatesDefaultWorkspace()
{
// Arrange
var request = new { Email = "workspace@example.com", Password = "password123" };
// Act
var response = await _client.PostAsJsonAsync("/auth/register", request);
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
result.Should().NotBeNull();
result!.User.Id.Should().NotBeEmpty();
// The workspace is created but not returned in auth response
// This could be verified with a separate workspaces endpoint test
}
[Fact]
public async Task Login_IsCaseInsensitive()
{
// Arrange
var email = "CaseTEST@Example.COM";
var password = "password123";
await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = password });
// Act - login with different casing
var response = await _client.PostAsJsonAsync("/auth/login",
new { Email = "casetest@example.com", Password = password });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
result!.User.Email.Should().Be("casetest@example.com");
}
}

View File

@@ -0,0 +1,231 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Domains.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace TrackQrApi.Tests;
public class DomainEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email,
bool upgradeToPro = true)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = result!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var workspacesResponse = await _client.GetAsync("/workspaces");
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = workspaces!.Workspaces.First().Id;
// Upgrade to Pro plan for domain tests (Free plan doesn't allow custom domains)
if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
return (token, workspaceId);
}
[Fact]
public async Task AddDomain_WithValidHostname_ReturnsCreated()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("add-domain@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "example.com" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<DomainResponse>();
result.Should().NotBeNull();
result!.Hostname.Should().Be("example.com");
result.Status.Should().Be("Pending");
result.VerificationToken.Should().NotBeNullOrEmpty();
result.VerificationRecord.Should().Contain("_trakqr-verification");
}
[Fact]
public async Task AddDomain_WithEmptyHostname_ReturnsBadRequest()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("add-domain-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task AddDomain_DuplicateHostname_ReturnsConflict()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("add-domain-dup@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
// Act
var response =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "duplicate.com" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
}
[Fact]
public async Task ListDomains_ReturnsDomainsForWorkspace()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("list-domains@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "list-test.com" });
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/domains");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<DomainListResponse>();
result.Should().NotBeNull();
result!.Domains.Should().Contain(d => d.Hostname == "list-test.com");
}
[Fact]
public async Task GetDomain_WithValidId_ReturnsDomain()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-domain@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "get-test.com" });
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/domains/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<DomainResponse>();
result!.Id.Should().Be(created.Id);
result.Hostname.Should().Be("get-test.com");
}
[Fact]
public async Task GetDomain_WithInvalidId_ReturnsNotFound()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-domain-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/domains/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task DeleteDomain_WithValidId_ReturnsSuccess()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("delete-domain@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "to-delete.com" });
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
// Act
var response = await _client.DeleteAsync($"/workspaces/{workspaceId}/domains/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Verify it's deleted
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId}/domains/{created.Id}");
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task VerifyDomain_WithUnverifiedDomain_ReturnsNotVerified()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("verify-domain@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains", new { Hostname = "unverified.com" });
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
// Act
var response =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<DomainVerificationResponse>();
result!.IsVerified.Should().BeFalse();
result.Status.Should().Be("Pending");
}
[Fact]
public async Task VerifyDomain_WithVerifiedPrefix_ReturnsVerified()
{
// Arrange
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("verify-domain-ok@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// The verification mock accepts domains starting with "verified-"
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains",
new { Hostname = "verified-test.com" });
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
// Act
var response =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/domains/{created!.Id}/verify", new { });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<DomainVerificationResponse>();
result!.IsVerified.Should().BeTrue();
result.Status.Should().Be("Verified");
}
[Fact]
public async Task Domain_CannotAccessOtherUsersWorkspace()
{
// Arrange
var (token1, workspaceId1) = await GetAuthAndWorkspaceAsync("domain-user1@example.com");
var (token2, _) = await GetAuthAndWorkspaceAsync("domain-user2@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/domains",
new { Hostname = "user1-domain.com" });
var created = await createResponse.Content.ReadFromJsonAsync<DomainResponse>();
// Act - Try to access as user2
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/domains/{created!.Id}");
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/domains/{created.Id}");
// Assert
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}

View File

@@ -0,0 +1,135 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace TrackQrApi.Tests;
public class EventTrackingTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string slug)
{
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = destinationUrl,
Slug = slug
});
return (await createResponse.Content.ReadFromJsonAsync<LinkResponse>())!;
}
[Fact]
public async Task Redirect_TracksClickEvent()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("event-track@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-track-link");
// Act - Click the link multiple times
await _noRedirectClient.GetAsync($"/{link.Slug}");
// Give time for async event tracking
await Task.Delay(500);
// We can't directly query the events without an analytics endpoint,
// but we can verify the redirect still works and the endpoint doesn't crash
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
[Fact]
public async Task Redirect_DeduplicatesEvents_WithinWindow()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("event-dedupe@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-dedupe-link");
// Act - Click the same link multiple times rapidly
var responses = new List<HttpResponseMessage>();
for (var i = 0; i < 5; i++) responses.Add(await _noRedirectClient.GetAsync($"/{link.Slug}"));
// Assert - All should redirect successfully (deduplication happens silently)
responses.Should().OnlyContain(r => r.StatusCode == HttpStatusCode.Redirect);
}
[Fact]
public async Task PasswordRedirect_TracksClickEvent()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("event-pass@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-pass-link");
await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{link.Id}", new { Password = "secret" });
// Act
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
[Fact]
public async Task Redirect_CapturesUserAgent()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("event-ua@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-ua-link");
// Set a custom user agent
_noRedirectClient.DefaultRequestHeaders.UserAgent.ParseAdd(
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X)");
// Act
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
// Assert - Redirect should work (event captures user agent in background)
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
[Fact]
public async Task Redirect_CapturesReferrer()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("event-ref@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "event-ref-link");
// Set a referrer
_noRedirectClient.DefaultRequestHeaders.Referrer = new Uri("https://twitter.com/somepost");
// Act
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
}

View File

@@ -0,0 +1,389 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Features.Projects.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace TrackQrApi.Tests;
public class LinkEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
[Fact]
public async Task CreateLink_WithValidData_ReturnsCreated()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
Title = "Example Link"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result.Should().NotBeNull();
result!.DestinationUrl.Should().Be("https://example.com");
result.Title.Should().Be("Example Link");
result.Slug.Should().NotBeNullOrEmpty();
result.Slug.Should().HaveLength(7); // Default slug length
result.Status.Should().Be("Active");
result.WorkspaceId.Should().Be(workspaceId);
}
[Fact]
public async Task CreateLink_WithCustomSlug_ReturnsCreatedWithSlug()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-slug@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
Slug = "my-custom-slug"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.Slug.Should().Be("my-custom-slug");
}
[Fact]
public async Task CreateLink_WithDuplicateSlug_ReturnsConflict()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-dup@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
Slug = "duplicate-slug"
});
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://another.com",
Slug = "duplicate-slug"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Conflict);
}
[Fact]
public async Task CreateLink_WithInvalidUrl_ReturnsBadRequest()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "not-a-valid-url"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task CreateLink_WithProject_AssignsToProject()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-link-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var projectResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
ProjectId = project!.Id
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.ProjectId.Should().Be(project.Id);
}
[Fact]
public async Task ListLinks_WithValidWorkspace_ReturnsLinks()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://example1.com" });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://example2.com" });
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkListResponse>();
result!.Links.Should().HaveCountGreaterThanOrEqualTo(2);
}
[Fact]
public async Task ListLinks_FilterByProject_ReturnsFilteredLinks()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-links-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var projectResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Filter Project" });
var project = await projectResponse.Content.ReadFromJsonAsync<ProjectResponse>();
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://in-project.com", ProjectId = project!.Id });
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links",
new { DestinationUrl = "https://no-project.com" });
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links?projectId={project.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkListResponse>();
result!.Links.Should().OnlyContain(l => l.ProjectId == project.Id);
}
[Fact]
public async Task GetLink_WithValidId_ReturnsLink()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-link@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
Title = "Get Test"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.Id.Should().Be(created.Id);
result.Title.Should().Be("Get Test");
}
[Fact]
public async Task GetLink_WithInvalidId_ReturnsNotFound()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-link-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/links/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task UpdateLink_WithValidData_ReturnsUpdated()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-link@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://original.com",
Title = "Original"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{created!.Id}", new
{
DestinationUrl = "https://updated.com",
Title = "Updated"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.DestinationUrl.Should().Be("https://updated.com");
result.Title.Should().Be("Updated");
}
[Fact]
public async Task UpdateLink_SetStatus_UpdatesStatus()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-link-status@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{created!.Id}", new
{
Status = "Disabled"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.Status.Should().Be("Disabled");
}
[Fact]
public async Task UpdateLink_SetPassword_AddsPassword()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-link-pass@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
created!.HasPassword.Should().BeFalse();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{created.Id}", new
{
Password = "secret123"
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.HasPassword.Should().BeTrue();
}
[Fact]
public async Task UpdateLink_RemovePassword_RemovesPassword()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-link-rmpass@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{created!.Id}", new { Password = "secret123" });
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{created.Id}", new
{
RemovePassword = true
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<LinkResponse>();
result!.HasPassword.Should().BeFalse();
}
[Fact]
public async Task DeleteLink_WithValidId_ReturnsSuccess()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("delete-link@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
// Act
var response = await _client.DeleteAsync($"/workspaces/{workspaceId}/links/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Verify it's deleted
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId}/links/{created.Id}");
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Link_CannotAccessOtherUsersLinks()
{
// Arrange - Create two users
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("user1-link@example.com");
var (token2, _) = await SetupAuthAndWorkspaceAsync("user2-link@example.com");
// Create link as user1
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/links", new
{
DestinationUrl = "https://user1.com"
});
var created = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
// Try to access as user2
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
// Act
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/links/{created!.Id}");
var updateResponse =
await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/links/{created.Id}", new { Title = "Hacked" });
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/links/{created.Id}");
// Assert - All should return NotFound (not exposing existence)
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}

View File

@@ -0,0 +1,203 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Projects.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace TrackQrApi.Tests;
public class ProjectEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Get the default workspace
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
[Fact]
public async Task ListProjects_WithValidWorkspace_ReturnsProjects()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("list-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/projects");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ProjectListResponse>();
result.Should().NotBeNull();
}
[Fact]
public async Task ListProjects_WithInvalidWorkspace_ReturnsNotFound()
{
// Arrange
var (token, _) = await SetupAuthAndWorkspaceAsync("list-proj-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{Guid.NewGuid()}/projects");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task CreateProject_WithValidData_ReturnsCreated()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Test Project" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<ProjectResponse>();
result.Should().NotBeNull();
result!.Name.Should().Be("Test Project");
result.WorkspaceId.Should().Be(workspaceId);
}
[Fact]
public async Task CreateProject_WithEmptyName_ReturnsBadRequest()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("create-proj-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task GetProject_WithValidId_ReturnsProject()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Get Test" });
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/projects/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ProjectResponse>();
result!.Id.Should().Be(created.Id);
result.Name.Should().Be("Get Test");
}
[Fact]
public async Task GetProject_WithInvalidId_ReturnsNotFound()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("get-proj-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/projects/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task UpdateProject_WithValidData_ReturnsUpdated()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("update-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "Original" });
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/projects/{created!.Id}",
new { Name = "Updated" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<ProjectResponse>();
result!.Name.Should().Be("Updated");
}
[Fact]
public async Task DeleteProject_WithValidId_ReturnsSuccess()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("delete-proj@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/projects", new { Name = "To Delete" });
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Act
var response = await _client.DeleteAsync($"/workspaces/{workspaceId}/projects/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Verify it's deleted
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId}/projects/{created.Id}");
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Project_CannotAccessOtherUsersProject()
{
// Arrange - Create two users
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("user1-proj@example.com");
var (token2, _) = await SetupAuthAndWorkspaceAsync("user2-proj@example.com");
// Create project as user1
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/projects", new { Name = "User1 Project" });
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>();
// Try to access as user2
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
// Act
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/projects/{created!.Id}");
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/projects/{created.Id}",
new { Name = "Hacked" });
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/projects/{created.Id}");
// Assert - All should return NotFound (not exposing existence)
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}

View File

@@ -0,0 +1,305 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Features.QRCodes.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace TrackQrApi.Tests;
public class QrCodeEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string slug)
{
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = "https://example.com",
Slug = slug
});
return (await createResponse.Content.ReadFromJsonAsync<LinkResponse>())!;
}
[Fact]
public async Task CreateQRCode_WithValidData_ReturnsCreated()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-create@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-create-link");
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new
{
ShortLinkId = link.Id
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<QRCodeResponse>();
result.Should().NotBeNull();
result!.ShortLinkId.Should().Be(link.Id);
result.ShortLinkSlug.Should().Be(link.Slug);
result.Style.Should().NotBeNull();
result.Style.ForegroundColor.Should().Be("#000000");
result.Style.BackgroundColor.Should().Be("#FFFFFF");
}
[Fact]
public async Task CreateQRCode_WithCustomStyle_ReturnsCreatedWithStyle()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-style@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-style-link");
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new
{
ShortLinkId = link.Id,
Style = new
{
ForegroundColor = "#FF0000",
BackgroundColor = "#00FF00",
ErrorCorrectionLevel = "H"
}
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<QRCodeResponse>();
result!.Style.ForegroundColor.Should().Be("#FF0000");
result.Style.BackgroundColor.Should().Be("#00FF00");
result.Style.ErrorCorrectionLevel.Should().Be("H");
}
[Fact]
public async Task CreateQRCode_WithoutShortLink_ReturnsBadRequest()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-nolink@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task ListQRCodes_ReturnsQRCodes()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-list@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-list-link");
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<QRCodeListResponse>();
result!.QRCodes.Should().HaveCountGreaterThanOrEqualTo(1);
}
[Fact]
public async Task GetQRCode_WithValidId_ReturnsQRCode()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-get@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-get-link");
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<QRCodeResponse>();
result!.Id.Should().Be(created.Id);
}
[Fact]
public async Task GetQRCode_WithInvalidId_ReturnsNotFound()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-get-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task UpdateQRCode_WithValidData_ReturnsUpdated()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-update@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-update-link");
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}", new
{
Style = new
{
ForegroundColor = "#0000FF",
BackgroundColor = "#FFFF00"
}
});
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<QRCodeResponse>();
result!.Style.ForegroundColor.Should().Be("#0000FF");
result.Style.BackgroundColor.Should().Be("#FFFF00");
}
[Fact]
public async Task DeleteQRCode_WithValidId_ReturnsSuccess()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-delete@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-delete-link");
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.DeleteAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Verify it's deleted
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{created.Id}");
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task PreviewQRCode_ReturnsDataUrl()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-preview@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-preview-link");
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}/preview");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<QRCodePreviewResponse>();
result!.DataUrl.Should().StartWith("data:image/png;base64,");
result.Format.Should().Be("png");
}
[Fact]
public async Task ExportQRCode_AsPng_ReturnsPngImage()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-export-png@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-export-png-link");
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}/export?format=png");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("image/png");
}
[Fact]
public async Task ExportQRCode_AsSvg_ReturnsSvgImage()
{
// Arrange
var (token, workspaceId) = await SetupAuthAndWorkspaceAsync("qr-export-svg@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var link = await CreateLinkAsync(workspaceId, "qr-export-svg-link");
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}/qrcodes/{created!.Id}/export?format=svg");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
response.Content.Headers.ContentType!.MediaType.Should().Be("image/svg+xml");
}
[Fact]
public async Task QRCode_CannotAccessOtherUsersQRCode()
{
// Arrange - Create two users
var (token1, workspaceId1) = await SetupAuthAndWorkspaceAsync("qr-user1@example.com");
var link = await CreateLinkAsync(workspaceId1, "qr-user1-link");
var createResponse =
await _client.PostAsJsonAsync($"/workspaces/{workspaceId1}/qrcodes", new { ShortLinkId = link.Id });
var created = await createResponse.Content.ReadFromJsonAsync<QRCodeResponse>();
var (token2, _) = await SetupAuthAndWorkspaceAsync("qr-user2@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
// Act
var getResponse = await _client.GetAsync($"/workspaces/{workspaceId1}/qrcodes/{created!.Id}");
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{workspaceId1}/qrcodes/{created.Id}", new { });
var deleteResponse = await _client.DeleteAsync($"/workspaces/{workspaceId1}/qrcodes/{created.Id}");
// Assert - All should return NotFound
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}

View File

@@ -0,0 +1,213 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using Microsoft.AspNetCore.Mvc.Testing;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Links.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace TrackQrApi.Tests;
public class RedirectEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private readonly HttpClient _noRedirectClient = factory.CreateClient(new WebApplicationFactoryClientOptions
{
AllowAutoRedirect = false
});
// Create a client that doesn't follow redirects
private async Task<(string Token, Guid WorkspaceId)> SetupAuthAndWorkspaceAsync(string email)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
var authResult = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = authResult!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var wsResponse = await _client.GetAsync("/workspaces");
var wsResult = await wsResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = wsResult!.Workspaces.First().Id;
return (token, workspaceId);
}
private async Task<LinkResponse> CreateLinkAsync(Guid workspaceId, string destinationUrl, string? slug = null,
string? password = null)
{
var createResponse = await _client.PostAsJsonAsync($"/workspaces/{workspaceId}/links", new
{
DestinationUrl = destinationUrl,
Slug = slug
});
var link = await createResponse.Content.ReadFromJsonAsync<LinkResponse>();
if (password != null)
{
await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{link!.Id}", new { Password = password });
var updated = await _client.GetAsync($"/workspaces/{workspaceId}/links/{link.Id}");
return (await updated.Content.ReadFromJsonAsync<LinkResponse>())!;
}
return link!;
}
[Fact]
public async Task Redirect_WithValidSlug_Returns302Redirect()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-valid@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "test-redirect");
// Act - Use no-redirect client to capture the redirect
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
response.Headers.Location.Should().NotBeNull();
response.Headers.Location!.ToString().Should().StartWith("https://example.com");
}
[Fact]
public async Task Redirect_WithNonExistentSlug_Returns404()
{
// Act
var response = await _client.GetAsync("/nonexistent-slug-12345");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Redirect_WithDisabledLink_Returns404()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-disabled@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "disabled-link");
// Disable the link
await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{link.Id}", new { Status = "Disabled" });
// Act
var response = await _client.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Redirect_WithExpiredLink_Returns410()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-expired@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "expired-link");
// Set expiration to the past
await _client.PutAsJsonAsync($"/workspaces/{workspaceId}/links/{link.Id}", new
{
ExpiresAt = DateTime.UtcNow.AddDays(-1)
});
// Act
var response = await _client.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Gone);
}
[Fact]
public async Task Redirect_WithPasswordProtectedLink_Returns401()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-password@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-link", "secret123");
// Act
var response = await _client.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
response.Headers.Contains("X-Password-Required").Should().BeTrue();
}
[Fact]
public async Task PasswordRedirect_WithCorrectPassword_Returns302Redirect()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-ok@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-ok-link", "secret123");
// Act
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "secret123" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
response.Headers.Location!.ToString().Should().StartWith("https://example.com");
}
[Fact]
public async Task PasswordRedirect_WithWrongPassword_Returns401()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-wrong@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-wrong-link", "secret123");
// Act
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "wrongpassword" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task PasswordRedirect_WithEmptyPassword_Returns400()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-pass-empty@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "password-empty-link", "secret123");
// Act
var response = await _client.PostAsJsonAsync($"/{link.Slug}", new { Password = "" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task PasswordRedirect_NonProtectedLink_RedirectsAnyway()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-no-pass@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "no-password-link");
// Act - POST to non-password protected link
var response = await _noRedirectClient.PostAsJsonAsync($"/{link.Slug}", new { Password = "anything" });
// Assert - Should still redirect since link doesn't require password
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
[Fact]
public async Task Redirect_DoesNotRequireAuthentication()
{
// Arrange
var (_, workspaceId) = await SetupAuthAndWorkspaceAsync("redirect-anon@example.com");
var link = await CreateLinkAsync(workspaceId, "https://example.com", "anon-redirect-link");
// Remove auth header
_noRedirectClient.DefaultRequestHeaders.Authorization = null;
// Act
var response = await _noRedirectClient.GetAsync($"/{link.Slug}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Redirect);
}
}

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4"/>
<PackageReference Include="FluentAssertions" Version="8.8.0"/>
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
<PackageReference Include="Testcontainers.PostgreSql" Version="4.10.0"/>
<PackageReference Include="xunit" Version="2.9.3"/>
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4"/>
</ItemGroup>
<ItemGroup>
<Using Include="Xunit"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\TrackQrApi\TrackQrApi.csproj"/>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,195 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using FluentAssertions;
using TrackQrApi.Features.Auth.Common;
using TrackQrApi.Features.Workspaces.Common;
namespace TrackQrApi.Tests;
public class WorkspaceEndpointTests(
ApiWebApplicationFactory factory)
: IClassFixture<ApiWebApplicationFactory>
{
private readonly HttpClient _client = factory.CreateClient();
private async Task<(string Token, Guid WorkspaceId)> GetAuthAndWorkspaceAsync(string email,
bool upgradeToPro = false)
{
var response = await _client.PostAsJsonAsync("/auth/register", new { Email = email, Password = "password123" });
if (response.StatusCode == HttpStatusCode.Conflict)
response = await _client.PostAsJsonAsync("/auth/login", new { Email = email, Password = "password123" });
var result = await response.Content.ReadFromJsonAsync<AuthResponse>();
var token = result!.Token;
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var workspacesResponse = await _client.GetAsync("/workspaces");
var workspaces = await workspacesResponse.Content.ReadFromJsonAsync<WorkspaceListResponse>();
var workspaceId = workspaces!.Workspaces.First().Id;
if (upgradeToPro) await factory.UpgradeWorkspaceToPro(workspaceId);
return (token, workspaceId);
}
private async Task<string> GetAuthTokenAsync(string email = "workspace-test@example.com")
{
var (token, _) = await GetAuthAndWorkspaceAsync(email);
return token;
}
[Fact]
public async Task ListWorkspaces_WithValidToken_ReturnsWorkspaces()
{
// Arrange
var token = await GetAuthTokenAsync("list-ws@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync("/workspaces");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<WorkspaceListResponse>();
result.Should().NotBeNull();
result!.Workspaces.Should().HaveCountGreaterThanOrEqualTo(1); // Default workspace created on registration
}
[Fact]
public async Task ListWorkspaces_WithoutToken_ReturnsUnauthorized()
{
// Act
var response = await _client.GetAsync("/workspaces");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Unauthorized);
}
[Fact]
public async Task CreateWorkspace_WithValidData_ReturnsCreated()
{
// Arrange - upgrade to Pro to allow creating additional workspaces
var (token, _) = await GetAuthAndWorkspaceAsync("create-ws@example.com", true);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync("/workspaces", new { Name = "Test Workspace" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.Created);
var result = await response.Content.ReadFromJsonAsync<WorkspaceResponse>();
result.Should().NotBeNull();
result!.Name.Should().Be("Test Workspace");
result.Plan.Should().Be("Free");
result.Id.Should().NotBeEmpty();
}
[Fact]
public async Task CreateWorkspace_WithEmptyName_ReturnsBadRequest()
{
// Arrange
var token = await GetAuthTokenAsync("create-ws-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PostAsJsonAsync("/workspaces", new { Name = "" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
[Fact]
public async Task GetWorkspace_WithValidId_ReturnsWorkspace()
{
// Arrange - use the default workspace (created on registration)
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("get-ws@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{workspaceId}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<WorkspaceResponse>();
result!.Id.Should().Be(workspaceId);
result.Name.Should().NotBeNullOrEmpty();
}
[Fact]
public async Task GetWorkspace_WithInvalidId_ReturnsNotFound()
{
// Arrange
var token = await GetAuthTokenAsync("get-ws-invalid@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.GetAsync($"/workspaces/{Guid.NewGuid()}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task UpdateWorkspace_WithValidData_ReturnsUpdated()
{
// Arrange - use the default workspace (created on registration)
var (token, workspaceId) = await GetAuthAndWorkspaceAsync("update-ws@example.com");
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// Act
var response = await _client.PutAsJsonAsync($"/workspaces/{workspaceId}", new { Name = "Updated Name" });
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
var result = await response.Content.ReadFromJsonAsync<WorkspaceResponse>();
result!.Name.Should().Be("Updated Name");
}
[Fact]
public async Task DeleteWorkspace_WithValidId_ReturnsSuccess()
{
// Arrange - upgrade to Pro to allow creating additional workspaces
var (token, _) = await GetAuthAndWorkspaceAsync("delete-ws@example.com", true);
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "To Delete" });
var created = await createResponse.Content.ReadFromJsonAsync<WorkspaceResponse>();
// Act
var response = await _client.DeleteAsync($"/workspaces/{created!.Id}");
// Assert
response.StatusCode.Should().Be(HttpStatusCode.OK);
// Verify it's deleted
var getResponse = await _client.GetAsync($"/workspaces/{created.Id}");
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
[Fact]
public async Task Workspace_CannotAccessOtherUsersWorkspace()
{
// Arrange - Create two users
var token1 = await GetAuthTokenAsync("user1-ws@example.com");
var token2 = await GetAuthTokenAsync("user2-ws@example.com");
// Create workspace as user1
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token1);
var createResponse = await _client.PostAsJsonAsync("/workspaces", new { Name = "User1 Workspace" });
var created = await createResponse.Content.ReadFromJsonAsync<WorkspaceResponse>();
// Try to access as user2
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token2);
// Act
var getResponse = await _client.GetAsync($"/workspaces/{created!.Id}");
var updateResponse = await _client.PutAsJsonAsync($"/workspaces/{created.Id}", new { Name = "Hacked" });
var deleteResponse = await _client.DeleteAsync($"/workspaces/{created.Id}");
// Assert - All should return NotFound (not exposing existence)
getResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
updateResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
deleteResponse.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
}