Skip to content

Commit 1e386d0

Browse files
authored
feat: Add comprehensive test suite (#276)
1 parent b4fd7a0 commit 1e386d0

37 files changed

Lines changed: 3337 additions & 67 deletions

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,4 +8,8 @@ Dev/dragonfly
88
Dev/postgres
99

1010
# Claude Code
11-
.claude/
11+
.claude/
12+
13+
# Code coverage
14+
TestResults/
15+
*.cobertura.xml

API.IntegrationTests/API.IntegrationTests.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
<!-- NuGet packages -->
1212
<ItemGroup>
1313
<PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" />
14+
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" />
1415
<PackageReference Include="Testcontainers.PostgreSql" />
1516
<PackageReference Include="Testcontainers.Redis" />
1617
<PackageReference Include="TUnit" />
18+
<PackageReference Include="Microsoft.Testing.Extensions.CodeCoverage" />
1719
</ItemGroup>
1820

1921
<!-- Git stuff -->
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
using TUnit.Core;
2+
using TUnit.Core.Interfaces;
3+
4+
// Allow up to 3 minutes per test — integration tests can be slow in CI when Docker images
5+
// are cold-pulled and EF migrations run for the first time. The execution timer in TUnit
6+
// may include class-data-source initialization time for the first test that uses the factory.
7+
[assembly: Timeout(3 * 60_000)]
8+
9+
// Limit parallel test execution to avoid thread pool starvation on CI runners.
10+
// BCrypt password hashing in login/signup endpoints is synchronous and CPU-bound;
11+
// too many concurrent tests exhaust the thread pool, causing request timeouts.
12+
[assembly: ParallelLimiter<OpenShock.API.IntegrationTests.CiSafeParallelLimit>]
13+
14+
namespace OpenShock.API.IntegrationTests;
15+
16+
public record CiSafeParallelLimit : IParallelLimit
17+
{
18+
public int Limit => Math.Max(Environment.ProcessorCount * 2, 8);
19+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
using DotNet.Testcontainers.Builders;
2+
using DotNet.Testcontainers.Containers;
3+
using TUnit.Core.Interfaces;
4+
5+
namespace OpenShock.API.IntegrationTests.Docker;
6+
7+
public sealed class TestMailServer : IAsyncInitializer, IAsyncDisposable
8+
{
9+
[ClassDataSource<DockerNetwork>(Shared = SharedType.PerTestSession)]
10+
public required DockerNetwork DockerNetwork { get; init; }
11+
12+
private IContainer? _container;
13+
public IContainer Container
14+
{
15+
get
16+
{
17+
_container ??= new ContainerBuilder("axllent/mailpit:latest")
18+
.WithNetwork(DockerNetwork.Instance)
19+
.WithName($"tunit-mailpit-{Guid.CreateVersion7()}")
20+
.WithPortBinding(1025, true)
21+
.WithPortBinding(8025, true)
22+
.WithWaitStrategy(Wait.ForUnixContainer()
23+
.UntilHttpRequestIsSucceeded(r => r.ForPort(8025).ForPath("/api/v1/info")))
24+
.Build();
25+
26+
return _container;
27+
}
28+
}
29+
30+
public string SmtpHost => Container.Hostname;
31+
public int SmtpPort => Container.GetMappedPublicPort(1025);
32+
public string ApiBaseUrl => $"http://{Container.Hostname}:{Container.GetMappedPublicPort(8025)}";
33+
34+
public Task InitializeAsync() => Container.StartAsync();
35+
public ValueTask DisposeAsync() => Container.DisposeAsync();
36+
}
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
using System.Net.Http.Json;
2+
using System.Text.Json.Serialization;
3+
4+
namespace OpenShock.API.IntegrationTests.Helpers;
5+
6+
/// <summary>
7+
/// Helper for querying the Mailpit HTTP API in integration tests.
8+
/// </summary>
9+
public sealed class MailpitHelper : IDisposable
10+
{
11+
private readonly HttpClient _client;
12+
13+
public MailpitHelper(string apiBaseUrl)
14+
{
15+
_client = new HttpClient { BaseAddress = new Uri(apiBaseUrl) };
16+
}
17+
18+
/// <summary>
19+
/// Polls until at least one email arrives for the given recipient address, or the timeout elapses.
20+
/// Returns null if no message arrived within the timeout.
21+
/// </summary>
22+
public async Task<MailpitMessage?> WaitForMessageAsync(
23+
string toAddress,
24+
TimeSpan? timeout = null,
25+
CancellationToken cancellationToken = default)
26+
{
27+
var deadline = DateTime.UtcNow + (timeout ?? TimeSpan.FromSeconds(15));
28+
while (DateTime.UtcNow < deadline && !cancellationToken.IsCancellationRequested)
29+
{
30+
var response = await _client.GetFromJsonAsync<MailpitSearchResponse>(
31+
"/api/v1/messages?limit=50", cancellationToken);
32+
33+
var match = response?.Messages?.FirstOrDefault(m =>
34+
m.To?.Any(c => c.Address.Equals(toAddress, StringComparison.OrdinalIgnoreCase)) == true);
35+
36+
if (match is not null)
37+
return match;
38+
39+
await Task.Delay(300, cancellationToken);
40+
}
41+
return null;
42+
}
43+
44+
/// <summary>
45+
/// Returns all messages in Mailpit (no filtering).
46+
/// </summary>
47+
public async Task<List<MailpitMessage>> GetAllMessagesAsync(
48+
int limit = 50,
49+
CancellationToken cancellationToken = default)
50+
{
51+
var response = await _client.GetFromJsonAsync<MailpitSearchResponse>(
52+
$"/api/v1/messages?limit={limit}", cancellationToken);
53+
return response?.Messages ?? [];
54+
}
55+
56+
/// <summary>
57+
/// Fetches the full HTML body of a message by its ID.
58+
/// </summary>
59+
public async Task<MailpitFullMessage?> GetMessageAsync(string messageId, CancellationToken cancellationToken = default)
60+
{
61+
return await _client.GetFromJsonAsync<MailpitFullMessage>(
62+
$"/api/v1/message/{messageId}",
63+
cancellationToken);
64+
}
65+
66+
/// <summary>
67+
/// Deletes all messages from Mailpit (useful for test isolation between test classes).
68+
/// </summary>
69+
public Task DeleteAllMessagesAsync(CancellationToken cancellationToken = default)
70+
=> _client.DeleteAsync("/api/v1/messages", cancellationToken);
71+
72+
public void Dispose() => _client.Dispose();
73+
74+
// --- DTOs ---
75+
76+
public sealed class MailpitSearchResponse
77+
{
78+
[JsonPropertyName("messages")]
79+
public List<MailpitMessage> Messages { get; init; } = [];
80+
}
81+
82+
public sealed class MailpitMessage
83+
{
84+
[JsonPropertyName("ID")]
85+
public string Id { get; init; } = string.Empty;
86+
87+
[JsonPropertyName("Subject")]
88+
public string Subject { get; init; } = string.Empty;
89+
90+
[JsonPropertyName("From")]
91+
public MailpitContact? From { get; init; }
92+
93+
[JsonPropertyName("To")]
94+
public List<MailpitContact>? To { get; init; }
95+
96+
[JsonPropertyName("Snippet")]
97+
public string Snippet { get; init; } = string.Empty;
98+
}
99+
100+
public sealed class MailpitFullMessage
101+
{
102+
[JsonPropertyName("ID")]
103+
public string Id { get; init; } = string.Empty;
104+
105+
[JsonPropertyName("Subject")]
106+
public string Subject { get; init; } = string.Empty;
107+
108+
[JsonPropertyName("From")]
109+
public MailpitContact? From { get; init; }
110+
111+
[JsonPropertyName("To")]
112+
public List<MailpitContact>? To { get; init; }
113+
114+
[JsonPropertyName("HTML")]
115+
public string Html { get; init; } = string.Empty;
116+
117+
[JsonPropertyName("Text")]
118+
public string Text { get; init; } = string.Empty;
119+
}
120+
121+
public sealed class MailpitContact
122+
{
123+
[JsonPropertyName("Name")]
124+
public string Name { get; init; } = string.Empty;
125+
126+
[JsonPropertyName("Address")]
127+
public string Address { get; init; } = string.Empty;
128+
}
129+
}
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
1+
using System.Collections.Concurrent;
2+
using System.Net;
3+
using System.Text;
4+
using System.Text.Json;
5+
using Microsoft.Extensions.DependencyInjection;
6+
using OpenShock.Common.Constants;
7+
using OpenShock.Common.OpenShockDb;
8+
using OpenShock.Common.Services.Session;
9+
using OpenShock.Common.Utils;
10+
11+
namespace OpenShock.API.IntegrationTests.Helpers;
12+
13+
public static class TestHelper
14+
{
15+
private static readonly JsonSerializerOptions JsonOptions = new()
16+
{
17+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
18+
};
19+
20+
/// <summary>
21+
/// Cache BCrypt hashes to avoid repeated expensive hashing across tests.
22+
/// BCrypt is synchronous and CPU-bound; hashing in every test causes thread pool
23+
/// starvation on CI runners with fewer cores, leading to test server timeouts.
24+
/// </summary>
25+
private static readonly ConcurrentDictionary<string, string> PasswordHashCache = new();
26+
27+
/// <summary>
28+
/// Creates a user directly in DB, creates a session via ISessionService, returns auth info.
29+
/// This bypasses signup/login endpoints entirely to avoid rate limiting.
30+
/// </summary>
31+
public static async Task<AuthenticatedUser> CreateAndLoginUser(
32+
WebApplicationFactory factory,
33+
string username,
34+
string email,
35+
string password)
36+
{
37+
// 1. Create user directly in DB
38+
var userId = await CreateUserInDb(factory, username, email, password);
39+
40+
// 2. Create session via ISessionService (stored in Redis)
41+
await using var scope = factory.Services.CreateAsyncScope();
42+
var sessionService = scope.ServiceProvider.GetRequiredService<ISessionService>();
43+
var session = await sessionService.CreateSessionAsync(userId, "IntegrationTest", "127.0.0.1");
44+
45+
return new AuthenticatedUser(userId, username, email, session.Token);
46+
}
47+
48+
/// <summary>
49+
/// Creates an HttpClient that sends the session cookie for authentication.
50+
/// </summary>
51+
public static HttpClient CreateAuthenticatedClient(WebApplicationFactory factory, string sessionToken)
52+
{
53+
var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
54+
{
55+
AllowAutoRedirect = false,
56+
HandleCookies = false
57+
});
58+
client.DefaultRequestHeaders.Add("Cookie", $"{AuthConstants.UserSessionCookieName}={sessionToken}");
59+
return client;
60+
}
61+
62+
/// <summary>
63+
/// Creates an HttpClient that sends an API token header for authentication.
64+
/// </summary>
65+
public static HttpClient CreateApiTokenClient(WebApplicationFactory factory, string apiToken)
66+
{
67+
var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
68+
{
69+
AllowAutoRedirect = false,
70+
HandleCookies = false
71+
});
72+
client.DefaultRequestHeaders.Add(AuthConstants.ApiTokenHeaderName, apiToken);
73+
return client;
74+
}
75+
76+
/// <summary>
77+
/// Creates an HttpClient that sends a hub/device token header for authentication.
78+
/// </summary>
79+
public static HttpClient CreateHubTokenClient(WebApplicationFactory factory, string hubToken)
80+
{
81+
var client = factory.CreateClient(new Microsoft.AspNetCore.Mvc.Testing.WebApplicationFactoryClientOptions
82+
{
83+
AllowAutoRedirect = false,
84+
HandleCookies = false
85+
});
86+
client.DefaultRequestHeaders.Add(AuthConstants.HubTokenHeaderName, hubToken);
87+
return client;
88+
}
89+
90+
/// <summary>
91+
/// Creates a user directly in the DB (bypasses signup endpoint).
92+
/// </summary>
93+
public static async Task<Guid> CreateUserInDb(
94+
WebApplicationFactory factory,
95+
string username,
96+
string email,
97+
string password,
98+
bool activated = true)
99+
{
100+
await using var scope = factory.Services.CreateAsyncScope();
101+
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
102+
103+
var userId = Guid.CreateVersion7();
104+
var hash = PasswordHashCache.GetOrAdd(password, HashingUtils.HashPassword);
105+
db.Users.Add(new User
106+
{
107+
Id = userId,
108+
Name = username,
109+
Email = email,
110+
PasswordHash = hash,
111+
ActivatedAt = activated ? DateTime.UtcNow : null
112+
});
113+
await db.SaveChangesAsync();
114+
return userId;
115+
}
116+
117+
/// <summary>
118+
/// Creates a device in the DB for a given user. Returns (deviceId, deviceToken).
119+
/// </summary>
120+
public static async Task<(Guid DeviceId, string Token)> CreateDeviceInDb(
121+
WebApplicationFactory factory,
122+
Guid ownerId,
123+
string name = "TestDevice")
124+
{
125+
await using var scope = factory.Services.CreateAsyncScope();
126+
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
127+
128+
var deviceId = Guid.CreateVersion7();
129+
var token = CryptoUtils.RandomAlphaNumericString(256);
130+
db.Devices.Add(new Device
131+
{
132+
Id = deviceId,
133+
Name = name,
134+
OwnerId = ownerId,
135+
Token = token,
136+
CreatedAt = DateTime.UtcNow
137+
});
138+
await db.SaveChangesAsync();
139+
return (deviceId, token);
140+
}
141+
142+
/// <summary>
143+
/// Creates an API token in the DB for a given user. Returns the raw token string.
144+
/// </summary>
145+
public static async Task<(Guid TokenId, string RawToken)> CreateApiTokenInDb(
146+
WebApplicationFactory factory,
147+
Guid userId,
148+
string name = "TestToken",
149+
List<Common.Models.PermissionType>? permissions = null)
150+
{
151+
await using var scope = factory.Services.CreateAsyncScope();
152+
var db = scope.ServiceProvider.GetRequiredService<OpenShockContext>();
153+
154+
var rawToken = CryptoUtils.RandomAlphaNumericString(AuthConstants.ApiTokenLength);
155+
var tokenId = Guid.CreateVersion7();
156+
db.ApiTokens.Add(new ApiToken
157+
{
158+
Id = tokenId,
159+
UserId = userId,
160+
Name = name,
161+
TokenHash = HashingUtils.HashToken(rawToken),
162+
CreatedByIp = IPAddress.Loopback,
163+
Permissions = permissions ?? [Common.Models.PermissionType.Shockers_Use]
164+
});
165+
await db.SaveChangesAsync();
166+
return (tokenId, rawToken);
167+
}
168+
169+
public static StringContent JsonContent(object obj)
170+
{
171+
return new StringContent(JsonSerializer.Serialize(obj, JsonOptions), Encoding.UTF8, "application/json");
172+
}
173+
}
174+
175+
public sealed record AuthenticatedUser(Guid Id, string Username, string Email, string SessionToken);

0 commit comments

Comments
 (0)