Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 27 additions & 4 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v3
- uses: actions/checkout@v5

- name: Setup .NET
uses: actions/setup-dotnet@v3
uses: actions/setup-dotnet@v5
with:
dotnet-version: "10.0.x"
dotnet-version: "9.0.x"

- name: Run Unit Tests
run: dotnet test PhantomDave.BankTracking.UnitTests/
Expand All @@ -21,9 +21,11 @@ jobs:
run: dotnet test PhantomDave.BankTracking.IntegrationTests/

- name: Setup Node
uses: actions/setup-node@v3
uses: actions/setup-node@v6
with:
node-version: "20"
cache: 'npm'
cache-dependency-path: frontend/package-lock.json

- name: Install Dependencies
working-directory: frontend
Expand All @@ -33,6 +35,27 @@ jobs:
working-directory: frontend
run: npx playwright install --with-deps chromium

- name: Start Database
run: docker compose -f compose.dev.yaml up -d database

Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing database health check. The database is started but there's no verification that it's ready before starting the API. The API startup could fail or encounter errors if it attempts to connect before PostgreSQL is fully initialized. Add a wait step similar to lines 46-57 to verify the database is ready (e.g., docker compose -f compose.dev.yaml exec database pg_isready).

Suggested change
- name: Wait for Database
run: |
for i in {1..30}; do
if docker compose -f compose.dev.yaml exec -T database pg_isready -U postgres; then
echo "Database is ready!"
exit 0
fi
echo "Waiting for database..."
sleep 2
done
echo "Database did not become ready in time" >&2
exit 1

Copilot uses AI. Check for mistakes.
- name: Start Backend API
run: |
dotnet build PhantomDave.BankTracking.Api/
nohup dotnet run --project PhantomDave.BankTracking.Api/ --urls=http://localhost:5095 > api.log 2>&1 &
Comment on lines +41 to +44
Copy link

Copilot AI Nov 17, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding a step to output API logs on failure for debugging. The API is started with output redirected to api.log, but if E2E tests fail, the logs aren't automatically displayed. Add a cleanup step with if: failure() that runs cat api.log to help diagnose issues.

Copilot uses AI. Check for mistakes.

- name: Wait for Backend API
run: |
for i in {1..30}; do
if curl -s http://localhost:5095/graphql > /dev/null; then
echo "API is up!"
exit 0
fi
echo "Waiting for API..."
sleep 2
done
echo "API did not start in time" >&2
exit 1

- name: Run E2E Tests
working-directory: frontend
run: npm run test:e2e
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,58 @@ namespace PhantomDave.BankTracking.IntegrationTests.GraphQL;
public class AccountIntegrationTests : IClassFixture<GraphQLTestFactory>
{
private readonly HttpClient _client;
private readonly GraphQLTestFactory _factory;

public AccountIntegrationTests(GraphQLTestFactory factory)
{
_factory = factory;
_client = factory.CreateClient();
}

private static string CreateAccountMutation(string email, string password) =>
$@"mutation {{
createAccount(email: ""{email}"", password: ""{password}"") {{
id
email
createdAt
}}
}}";

private static string LoginAccountMutation(string email, string password) =>
$@"mutation {{
loginAccount(email: ""{email}"", password: ""{password}"") {{
token
account {{
id
email
}}
}}
}}";

private static string SafeGetToken(string jsonContent)
{
using var doc = JsonDocument.Parse(jsonContent);
if (!doc.RootElement.TryGetProperty("data", out var dataElem))
throw new Xunit.Sdk.XunitException("Response JSON does not contain 'data' property: " + jsonContent);
if (!dataElem.TryGetProperty("loginAccount", out var loginAccountElem))
throw new Xunit.Sdk.XunitException("Response JSON does not contain 'loginAccount' property: " + jsonContent);
if (!loginAccountElem.TryGetProperty("token", out var tokenElem))
throw new Xunit.Sdk.XunitException("Response JSON does not contain 'token' property: " + jsonContent);
return tokenElem.GetString() ?? throw new Xunit.Sdk.XunitException("Token is null");
}

private static int SafeGetAccountId(string jsonContent)
{
using var doc = JsonDocument.Parse(jsonContent);
if (!doc.RootElement.TryGetProperty("data", out var dataElem))
throw new Xunit.Sdk.XunitException("Response JSON does not contain 'data' property: " + jsonContent);
if (!dataElem.TryGetProperty("loginAccount", out var loginAccountElem))
throw new Xunit.Sdk.XunitException("Response JSON does not contain 'loginAccount' property: " + jsonContent);
if (!loginAccountElem.TryGetProperty("account", out var accountElem))
throw new Xunit.Sdk.XunitException("Response JSON does not contain 'account' property: " + jsonContent);
if (!accountElem.TryGetProperty("id", out var idElem))
throw new Xunit.Sdk.XunitException("Response JSON does not contain 'id' property: " + jsonContent);
return idElem.GetInt32();
}

[Fact]
public async Task GraphQL_Endpoint_IsAccessible()
{
Expand All @@ -33,22 +77,10 @@ public async Task GraphQL_Endpoint_IsAccessible()
public async Task CreateAccount_WithValidData_ReturnsSuccess()
{
// Arrange
var query = @"
mutation {
createAccount(email: ""test@example.com"", password: ""Password123!"") {
id
email
createdAt
}
}";

var request = new
{
query = query
};
var query = CreateAccountMutation("test@example.com", "Password123!");

// Act
var response = await _client.PostAsJsonAsync("/graphql", request);
var response = await _client.PostAsJsonAsync("/graphql", new { query });

// Assert
response.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
Expand All @@ -69,15 +101,13 @@ public async Task CreateAccount_WithValidData_ReturnsSuccess()
public async Task CreateAccount_WithDuplicateEmail_ReturnsError()
{
// Arrange
var createQuery = @"
mutation {
createAccount(email: ""duplicate@example.com"", password: ""Password123!"") {
id
email
}
}";
var createQuery = CreateAccountMutation("duplicate@example.com", "Password123!");

await _client.PostAsJsonAsync("/graphql", new { query = createQuery });
var firstResponse = await _client.PostAsJsonAsync("/graphql", new { query = createQuery });
firstResponse.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
var firstContent = await firstResponse.Content.ReadAsStringAsync();
firstContent.Should().NotContain("\"errors\"", "First account creation should succeed to properly test duplicate scenario");
firstContent.Should().Contain("\"id\"", "Account creation response should contain an id");

// Act
var response = await _client.PostAsJsonAsync("/graphql", new { query = createQuery });
Expand All @@ -94,25 +124,14 @@ public async Task Login_WithValidCredentials_ReturnsToken()
var email = "login@example.com";
var password = "Password123!";

var createQuery = $@"
mutation {{
createAccount(email: ""{email}"", password: ""{password}"") {{
id
}}
}}";

await _client.PostAsJsonAsync("/graphql", new { query = createQuery });
var createQuery = CreateAccountMutation(email, password);
var createResponse = await _client.PostAsJsonAsync("/graphql", new { query = createQuery });
var createContent = await createResponse.Content.ReadAsStringAsync();
createResponse.StatusCode.Should().Be(System.Net.HttpStatusCode.OK);
createContent.Should().NotContain("\"errors\"", "Account creation should succeed before attempting login");
createContent.Should().Contain("\"id\"", "Account creation response should contain an id");

var loginQuery = $@"
mutation {{
loginAccount(email: ""{email}"", password: ""{password}"") {{
token
account {{
id
email
}}
}}
}}";
var loginQuery = LoginAccountMutation(email, password);

// Act
var response = await _client.PostAsJsonAsync("/graphql", new { query = loginQuery });
Expand All @@ -132,21 +151,10 @@ public async Task Login_WithInvalidPassword_ReturnsError()
var correctPassword = "CorrectPassword123!";
var wrongPassword = "WrongPassword123!";

var createQuery = $@"
mutation {{
createAccount(email: ""{email}"", password: ""{correctPassword}"") {{
id
}}
}}";

var createQuery = CreateAccountMutation(email, correctPassword);
await _client.PostAsJsonAsync("/graphql", new { query = createQuery });

var loginQuery = $@"
mutation {{
loginAccount(email: ""{email}"", password: ""{wrongPassword}"") {{
token
}}
}}";
var loginQuery = LoginAccountMutation(email, wrongPassword);

// Act
var response = await _client.PostAsJsonAsync("/graphql", new { query = loginQuery });
Expand All @@ -163,27 +171,14 @@ public async Task VerifyToken_WithValidToken_ReturnsAccountInfo()
var email = "verify@example.com";
var password = "Password123!";

var createQuery = $@"
mutation {{
createAccount(email: ""{email}"", password: ""{password}"") {{
id
}}
}}";

var createQuery = CreateAccountMutation(email, password);
await _client.PostAsJsonAsync("/graphql", new { query = createQuery });

var loginQuery = $@"
mutation {{
loginAccount(email: ""{email}"", password: ""{password}"") {{
token
}}
}}";

var loginQuery = LoginAccountMutation(email, password);
var loginResponse = await _client.PostAsJsonAsync("/graphql", new { query = loginQuery });
var loginContent = await loginResponse.Content.ReadAsStringAsync();

using var doc = JsonDocument.Parse(loginContent);
var token = doc.RootElement.GetProperty("data").GetProperty("loginAccount").GetProperty("token").GetString();
var token = SafeGetToken(loginContent);

var verifyQuery = $@"
mutation {{
Expand All @@ -209,31 +204,15 @@ public async Task GetAccount_WithAuthentication_ReturnsAccountData()
var email = "getaccount@example.com";
var password = "Password123!";

var createQuery = $@"
mutation {{
createAccount(email: ""{email}"", password: ""{password}"") {{
id
}}
}}";

var createQuery = CreateAccountMutation(email, password);
await _client.PostAsJsonAsync("/graphql", new { query = createQuery });

var loginQuery = $@"
mutation {{
loginAccount(email: ""{email}"", password: ""{password}"") {{
token
account {{
id
}}
}}
}}";

var loginQuery = LoginAccountMutation(email, password);
var loginResponse = await _client.PostAsJsonAsync("/graphql", new { query = loginQuery });
var loginContent = await loginResponse.Content.ReadAsStringAsync();

using var doc = JsonDocument.Parse(loginContent);
var token = doc.RootElement.GetProperty("data").GetProperty("loginAccount").GetProperty("token").GetString();
var accountId = doc.RootElement.GetProperty("data").GetProperty("loginAccount").GetProperty("account").GetProperty("id").GetInt32();
var token = SafeGetToken(loginContent);
var accountId = SafeGetAccountId(loginContent);

_client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
{
config.AddInMemoryCollection(new Dictionary<string, string?>
{
["ConnectionStrings:DefaultConnection"] = "Host=localhost;Database=banktrackertest;Username=test;Password=test",
["Jwt:Secret"] = "ThisIsASecretKeyForTestingPurposesOnly123456789",
["Jwt:Issuer"] = "BankTrackerTestIssuer",
["Jwt:Audience"] = "BankTrackerTestAudience",
Expand All @@ -37,7 +36,7 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)

services.AddDbContext<BankTrackerDbContext>(options =>
{
options.UseInMemoryDatabase("InMemoryTestDb");
options.UseInMemoryDatabase($"InMemoryTestDb_{Guid.NewGuid()}");
});
});

Expand Down
39 changes: 22 additions & 17 deletions PhantomDave.BankTracking.UnitTests/Services/AccountServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -244,23 +244,28 @@ public async Task LoginAccountAsync_WithNonExistentEmail_ReturnsNull()
Assert.Null(result);
}

private async Task<Account> CreateTestAccountWithPassword(string email, string password)
private static string HashPassword(string password)
{
var tempMockRepo = new Mock<IRepository<Account>>();
var tempMockUow = new Mock<IUnitOfWork>();
tempMockUow.Setup(u => u.Accounts).Returns(tempMockRepo.Object);

tempMockRepo.Setup(r => r.GetSingleOrDefaultAsync(It.IsAny<Expression<Func<Account, bool>>>()))
.ReturnsAsync((Account?)null);

tempMockRepo.Setup(r => r.AddAsync(It.IsAny<Account>()))
.ReturnsAsync((Account a) => a);

tempMockUow.Setup(u => u.SaveChangesAsync())
.ReturnsAsync(1);

var tempService = new AccountService(tempMockUow.Object);
var account = await tempService.CreateAccountAsync(email, password);
return account!;
const int iterations = 100_000;
const int saltSize = 16;
const int keySize = 32;

var salt = System.Security.Cryptography.RandomNumberGenerator.GetBytes(saltSize);
var hash = System.Security.Cryptography.Rfc2898DeriveBytes.Pbkdf2(
password, salt, iterations, System.Security.Cryptography.HashAlgorithmName.SHA256, keySize);

return $"PBKDF2-SHA256${iterations}${Convert.ToBase64String(salt)}${Convert.ToBase64String(hash)}";
}

private Task<Account> CreateTestAccountWithPassword(string email, string password)
{
var account = new Account
{
Id = 1,
Email = email,
PasswordHash = HashPassword(password),
CreatedAt = DateTime.UtcNow
};
return Task.FromResult(account);
}
}
Loading
Loading