Skip to content
Merged
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
114 changes: 114 additions & 0 deletions backend/tests/Taskdeck.Api.Tests/BoardRepositoryIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
using System.Collections.Concurrent;
using System.Data.Common;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.DependencyInjection;
using Taskdeck.Application.Interfaces;
using Taskdeck.Domain.Entities;
using Taskdeck.Domain.Enums;
using Taskdeck.Infrastructure.Persistence;
using Taskdeck.Infrastructure.Repositories;
using Xunit;

namespace Taskdeck.Api.Tests;
Expand Down Expand Up @@ -141,6 +146,87 @@ public async Task GetByIdWithDetailsAsync_ShouldLoadDeepIncludeChain()
loadedCard.CardLabels.First().Label.Should().NotBeNull();
}

/// <summary>
/// Regression for #1133: the graph-correctness assertion alone cannot distinguish the
/// split-query implementation from a single cartesian fan-out query. Capture the commands
/// for only this read and prove that the real SQLite provider executed multiple SELECTs.
/// </summary>
[Fact]
public async Task GetByIdWithDetailsAsync_WithMultipleCollections_ExecutesSplitSelects()
{
var dbPath = Path.Combine(Path.GetTempPath(), $"taskdeck-board-split-{Guid.NewGuid():N}.db");
var interceptor = new CapturingCommandInterceptor();
try
{
var options = new DbContextOptionsBuilder<TaskdeckDbContext>()
.UseSqlite(TestSqlite.ConnectionString(dbPath))
.AddInterceptors(interceptor)
.Options;

await using var db = new TaskdeckDbContext(options);
await db.Database.MigrateAsync();

var user = new User("brd-split-user", "brd-split@example.com", "hash");
var board = new Board("Split-query board", ownerId: user.Id);
var firstColumn = new Column(board.Id, "Todo", 0);
var secondColumn = new Column(board.Id, "Done", 1);
var firstLabel = new Label(board.Id, "Bug", "#FF0000");
var secondLabel = new Label(board.Id, "Feature", "#00FF00");
db.Users.Add(user);
db.Boards.Add(board);
db.Columns.AddRange(firstColumn, secondColumn);
db.Labels.AddRange(firstLabel, secondLabel);
await db.SaveChangesAsync();

var firstCard = new Card(board.Id, firstColumn.Id, "Fix the query");
var secondCard = new Card(board.Id, secondColumn.Id, "Ship the proof");
db.Cards.AddRange(firstCard, secondCard);
await db.SaveChangesAsync();

db.CardLabels.AddRange(
new CardLabel(firstCard.Id, firstLabel.Id),
new CardLabel(secondCard.Id, secondLabel.Id));
await db.SaveChangesAsync();

db.ChangeTracker.Clear();
interceptor.Clear();

var result = await new BoardRepository(db).GetByIdWithDetailsAsync(board.Id);

result.Should().NotBeNull();
result!.Columns.Should().HaveCount(2);
result.Columns.SelectMany(column => column.Cards).Should().HaveCount(2);
result.Labels.Should().HaveCount(2);
result.Columns
.SelectMany(column => column.Cards)
.SelectMany(card => card.CardLabels)
.Should()
.HaveCount(2)
.And.OnlyContain(cardLabel => cardLabel.Label != null);

var selectCommands = interceptor.Snapshot()
.Where(sql => sql.TrimStart().StartsWith("SELECT", StringComparison.OrdinalIgnoreCase))
.ToList();
selectCommands.Should().HaveCountGreaterThan(
1,
"the board's sibling collections must be loaded by multiple real SQLite SELECTs");
}
finally
{
foreach (var suffix in new[] { "", "-wal", "-shm", "-journal", ".migrate.lock" })
{
var path = dbPath + suffix;
if (!File.Exists(path))
{
continue;
}

try { File.Delete(path); }
catch (IOException) { /* best-effort test cleanup */ }
}
}
}

[Fact]
public async Task GetRecentReadableByUserIdAsync_ShouldRespectLimit()
{
Expand Down Expand Up @@ -424,4 +510,32 @@ public async Task CountCollaborationMembersAsync_ShouldCountGranteeOnly_ForAnOwn

(await repo.CountCollaborationMembersAsync(grantee.Id)).Should().Be(1);
}

private sealed class CapturingCommandInterceptor : DbCommandInterceptor
{
private readonly ConcurrentQueue<string> _commands = new();

public IReadOnlyList<string> Snapshot() => _commands.ToArray();

public void Clear() => _commands.Clear();

public override InterceptionResult<DbDataReader> ReaderExecuting(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result)
{
_commands.Enqueue(command.CommandText);
return base.ReaderExecuting(command, eventData, result);
}

public override ValueTask<InterceptionResult<DbDataReader>> ReaderExecutingAsync(
DbCommand command,
CommandEventData eventData,
InterceptionResult<DbDataReader> result,
CancellationToken cancellationToken = default)
{
_commands.Enqueue(command.CommandText);
return base.ReaderExecutingAsync(command, eventData, result, cancellationToken);
}
}
}
Loading