diff --git a/AS400API.Tests/AS400API.Tests.csproj b/AS400API.Tests/AS400API.Tests.csproj
new file mode 100644
index 0000000..21733cb
--- /dev/null
+++ b/AS400API.Tests/AS400API.Tests.csproj
@@ -0,0 +1,18 @@
+
+
+ net9.0
+ enable
+ enable
+ false
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AS400API.Tests/ApiIntegrationTests.cs b/AS400API.Tests/ApiIntegrationTests.cs
new file mode 100644
index 0000000..81563c0
--- /dev/null
+++ b/AS400API.Tests/ApiIntegrationTests.cs
@@ -0,0 +1,107 @@
+using System.Net;
+using System.Net.Http.Headers;
+using System.Net.Http.Json;
+using System.Text.Json;
+using AS400API.Auth;
+using Microsoft.AspNetCore.Mvc.Testing;
+using Xunit;
+
+namespace AS400API.Tests;
+
+public sealed class ApiIntegrationTests : IClassFixture>
+{
+ private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
+ {
+ PropertyNameCaseInsensitive = true
+ };
+
+ private readonly WebApplicationFactory _factory;
+
+ public ApiIntegrationTests(WebApplicationFactory factory)
+ {
+ _factory = factory.WithWebHostBuilder(builder =>
+ {
+ builder.UseSetting("DOTNET_ENVIRONMENT", "Development");
+ });
+ }
+
+ [Fact]
+ public async Task GetRoot_ReturnsExpectedPayload()
+ {
+ var client = _factory.CreateClient();
+
+ var response = await client.GetAsync("/");
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.True(response.IsSuccessStatusCode, FormatFailure("GET /", response.StatusCode, body));
+
+ var payload = JsonSerializer.Deserialize(body, JsonOptions);
+ Assert.NotNull(payload);
+ Assert.Equal("AS400API", payload!.Name);
+ Assert.Equal("ok", payload.Status);
+ }
+
+ [Fact]
+ public async Task Login_WithValidCredentials_ReturnsToken()
+ {
+ var client = _factory.CreateClient();
+ var request = new LoginRequest("admin", "Pass@123");
+
+ var response = await client.PostAsJsonAsync("/api/v1/auth/login", request, JsonOptions);
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.True(response.IsSuccessStatusCode, FormatFailure("POST /api/v1/auth/login", response.StatusCode, body));
+
+ var payload = JsonSerializer.Deserialize(body, JsonOptions);
+ Assert.NotNull(payload);
+ Assert.Equal("Bearer", payload!.TokenType);
+ Assert.True(payload.ExpiresIn > 0);
+ Assert.False(string.IsNullOrWhiteSpace(payload.AccessToken));
+ Assert.Contains("Admin", payload.Roles);
+ }
+
+ [Fact]
+ public async Task Login_WithInvalidCredentials_ReturnsUnauthorized()
+ {
+ var client = _factory.CreateClient();
+ var request = new LoginRequest("admin", "wrong-password");
+
+ var response = await client.PostAsJsonAsync("/api/v1/auth/login", request, JsonOptions);
+ var body = await response.Content.ReadAsStringAsync();
+
+ Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+ Assert.True(string.IsNullOrEmpty(body) || body.Contains("error", StringComparison.OrdinalIgnoreCase));
+ }
+
+ [Fact]
+ public async Task UsersMe_WithValidToken_ReturnsProfile()
+ {
+ var client = _factory.CreateClient();
+ var loginResponse = await client.PostAsJsonAsync("/api/v1/auth/login", new LoginRequest("operator", "Pass@123"), JsonOptions);
+ var loginBody = await loginResponse.Content.ReadAsStringAsync();
+ Assert.True(loginResponse.IsSuccessStatusCode, FormatFailure("POST /api/v1/auth/login", loginResponse.StatusCode, loginBody));
+
+ var tokenPayload = JsonSerializer.Deserialize(loginBody, JsonOptions);
+ Assert.NotNull(tokenPayload);
+
+ client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", tokenPayload!.AccessToken);
+
+ var profileResponse = await client.GetAsync("/api/v1/users/me");
+ var profileBody = await profileResponse.Content.ReadAsStringAsync();
+ Assert.True(profileResponse.IsSuccessStatusCode, FormatFailure("GET /api/v1/users/me", profileResponse.StatusCode, profileBody));
+
+ var profile = JsonSerializer.Deserialize(profileBody, JsonOptions);
+ Assert.NotNull(profile);
+ Assert.Equal("operator", profile!.Username);
+ Assert.Contains("Operator", profile.Roles);
+ }
+
+ private static string FormatFailure(string action, HttpStatusCode status, string body) =>
+ $"{action} failed with status {(int)status} ({status}). Body: {body}";
+
+ private sealed record RootResponse(string Name, string Status);
+
+ private sealed record LoginRequest(string Username, string Password);
+
+ private sealed record UserProfileResponse(string Username, string[] Roles);
+}
diff --git a/AS400API.csproj b/AS400API.csproj
index 96f19c1..6dd969a 100644
--- a/AS400API.csproj
+++ b/AS400API.csproj
@@ -14,4 +14,7 @@
+
+
+
diff --git a/AS400API.sln b/AS400API.sln
index 53f84e0..94e2d91 100644
--- a/AS400API.sln
+++ b/AS400API.sln
@@ -1,19 +1,46 @@
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.5.2.0
MinimumVisualStudioVersion = 10.0.40219.1
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AS400API", "AS400API.csproj", "{40CB5B53-77B8-B1FD-458C-805350CB09E8}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AS400API.Tests", "AS400API.Tests\AS400API.Tests.csproj", "{37A21E3B-3EEB-4538-B085-0FD6BA85DE70}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
+ Debug|x64 = Debug|x64
+ Debug|x86 = Debug|x86
Release|Any CPU = Release|Any CPU
+ Release|x64 = Release|x64
+ Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|x64.Build.0 = Debug|Any CPU
+ {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Debug|x86.Build.0 = Debug|Any CPU
{40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|x64.ActiveCfg = Release|Any CPU
+ {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|x64.Build.0 = Release|Any CPU
+ {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|x86.ActiveCfg = Release|Any CPU
+ {40CB5B53-77B8-B1FD-458C-805350CB09E8}.Release|x86.Build.0 = Release|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|x64.ActiveCfg = Debug|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|x64.Build.0 = Debug|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|x86.ActiveCfg = Debug|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Debug|x86.Build.0 = Debug|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|Any CPU.Build.0 = Release|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|x64.ActiveCfg = Release|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|x64.Build.0 = Release|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|x86.ActiveCfg = Release|Any CPU
+ {37A21E3B-3EEB-4538-B085-0FD6BA85DE70}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Program.cs b/Program.cs
index bae89c2..c7f4ed5 100644
--- a/Program.cs
+++ b/Program.cs
@@ -131,3 +131,5 @@ app.Run();
// env DOTNET_ENVIRONMENT=Development dotnet run
// docker run -d --name sonarqube -p 9000:9000 sonarqube:lts-community
+
+public partial class Program { }