using ClaudeDo.Data;
using ClaudeDo.Data.Models;
using ClaudeDo.Data.Repositories;
using ClaudeDo.Worker.Online;
using ClaudeDo.Worker.Tests.Infrastructure;
using Microsoft.Extensions.Logging.Abstractions;
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
namespace ClaudeDo.Worker.Tests.Online;
///
/// Integration tests for using a fake API + real SQLite.
///
public sealed class OnlineSyncServiceTests : IDisposable
{
private readonly DbFixture _db = new();
public void Dispose() => _db.Dispose();
// ---- fake API ----
private sealed class FakeApi : IOnlineInboxApi
{
public List UnimportedTasks { get; set; } = [];
public List ReceivedLists { get; } = [];
public List ReceivedMirror { get; } = [];
public List MarkedImported { get; } = [];
public int CallCount { get; private set; }
public Task PutListsAsync(IReadOnlyList lists, CancellationToken ct = default)
{
CallCount++;
ReceivedLists.AddRange(lists);
return Task.CompletedTask;
}
public Task> GetUnimportedTasksAsync(CancellationToken ct = default)
{
CallCount++;
return Task.FromResult>(UnimportedTasks);
}
public Task MarkImportedAsync(string id, CancellationToken ct = default)
{
CallCount++;
MarkedImported.Add(id);
return Task.CompletedTask;
}
public Task PutMirrorAsync(IReadOnlyList tasks, CancellationToken ct = default)
{
CallCount++;
ReceivedMirror.AddRange(tasks);
return Task.CompletedTask;
}
}
private OnlineSyncService BuildService(FakeApi api, string? token = "test-token")
{
var config = new OnlineInboxConfig { Enabled = true, PollIntervalSeconds = 60 };
var auth = new StaticTokenAuthProvider(token);
return new OnlineSyncService(
_db.CreateFactory(),
api,
auth,
config,
NullLogger.Instance);
}
private async Task<(string ListId, ClaudeDoDbContext Ctx, TaskRepository Tasks, ListRepository Lists)> SeedAsync()
{
var ctx = _db.CreateContext();
var lists = new ListRepository(ctx);
var tasks = new TaskRepository(ctx);
var listId = Guid.NewGuid().ToString();
await lists.AddAsync(new ListEntity { Id = listId, Name = "MyList", CreatedAt = DateTime.UtcNow });
return (listId, ctx, tasks, lists);
}
// ---- pull → import → flag ----
[Fact]
public async Task Tick_Imports_RemoteTask_And_MarksImported()
{
var (listId, ctx, tasks, _) = await SeedAsync();
using var _ = ctx;
var remoteId = Guid.NewGuid().ToString();
var api = new FakeApi
{
UnimportedTasks = [new RemoteTask(remoteId, listId, "From Web", "desc", DateTimeOffset.UtcNow)],
};
var svc = BuildService(api);
await svc.TickAsync(CancellationToken.None);
var imported = await tasks.GetByIdAsync(remoteId);
Assert.NotNull(imported);
Assert.Equal("From Web", imported!.Title);
Assert.Equal("desc", imported.Description);
Assert.Equal(TaskStatus.Idle, imported.Status);
Assert.Equal("online", imported.CreatedBy);
Assert.Contains(remoteId, api.MarkedImported);
}
[Fact]
public async Task Tick_UnknownList_Skips_And_DoesNotMark()
{
var _ = await SeedAsync();
var remoteId = Guid.NewGuid().ToString();
var api = new FakeApi
{
UnimportedTasks = [new RemoteTask(remoteId, "unknown-list-id", "T", null, DateTimeOffset.UtcNow)],
};
var svc = BuildService(api);
await svc.TickAsync(CancellationToken.None);
using var ctx = _db.CreateContext();
var tasks = new TaskRepository(ctx);
Assert.Null(await tasks.GetByIdAsync(remoteId));
Assert.DoesNotContain(remoteId, api.MarkedImported);
}
// ---- mirror == Idle backlog ----
[Fact]
public async Task Tick_Mirror_Contains_Idle_Backlog()
{
var (listId, ctx, tasks, _) = await SeedAsync();
using var _ = ctx;
// Idle task → should appear in mirror
var idle = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Idle Task",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
};
await tasks.AddAsync(idle);
// Queued task → must NOT appear
var queued = new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Queued",
Status = TaskStatus.Queued, CreatedAt = DateTime.UtcNow,
};
await tasks.AddAsync(queued);
var api = new FakeApi();
var svc = BuildService(api);
await svc.TickAsync(CancellationToken.None);
var mirrorIds = api.ReceivedMirror.Select(m => m.Id).ToHashSet();
Assert.Contains(idle.Id, mirrorIds);
Assert.DoesNotContain(queued.Id, mirrorIds);
}
[Fact]
public async Task Tick_ImportedTask_IncludedInMirror()
{
// Newly imported tasks must be part of the mirror sent in the same cycle (order matters).
var (listId, ctx, tasks, _) = await SeedAsync();
using var _ = ctx;
var remoteId = Guid.NewGuid().ToString();
var api = new FakeApi
{
UnimportedTasks = [new RemoteTask(remoteId, listId, "New", null, DateTimeOffset.UtcNow)],
};
var svc = BuildService(api);
await svc.TickAsync(CancellationToken.None);
// Imported task lands Idle → must be in the mirror payload
Assert.Contains(api.ReceivedMirror, m => m.Id == remoteId);
}
// ---- lists pushed ----
[Fact]
public async Task Tick_Pushes_AllLists()
{
var (listId, ctx, _, lists) = await SeedAsync();
using var _ = ctx;
// Add a second list
var listId2 = Guid.NewGuid().ToString();
await lists.AddAsync(new ListEntity { Id = listId2, Name = "List2", CreatedAt = DateTime.UtcNow });
var api = new FakeApi();
var svc = BuildService(api);
await svc.TickAsync(CancellationToken.None);
var pushedIds = api.ReceivedLists.Select(l => l.Id).ToHashSet();
Assert.Contains(listId, pushedIds);
Assert.Contains(listId2, pushedIds);
}
// ---- no token = no calls ----
[Fact]
public async Task Tick_NoToken_SkipsCycle_NoApiCalls()
{
_ = await SeedAsync();
var api = new FakeApi();
var svc = BuildService(api, token: null);
await svc.TickAsync(CancellationToken.None);
Assert.Equal(0, api.CallCount);
}
// ---- multi-user: owner stamping + guard ----
[Fact]
public async Task Tick_StampsOwnerId_OnPushedListsAndMirror()
{
var (listId, ctx, tasks, _) = await SeedAsync();
using var _ = ctx;
await tasks.AddAsync(new TaskEntity
{
Id = Guid.NewGuid().ToString(), ListId = listId, Title = "Idle",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
});
var token = JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}");
var api = new FakeApi();
var svc = BuildService(api, token);
await svc.TickAsync(CancellationToken.None);
Assert.All(api.ReceivedLists, l => Assert.Equal("owner-1", l.OwnerId));
Assert.NotEmpty(api.ReceivedMirror);
Assert.All(api.ReceivedMirror, m => Assert.Equal("owner-1", m.OwnerId));
}
[Fact]
public async Task Tick_SkipsRemoteTask_OwnedByAnotherUser()
{
var (listId, ctx, tasks, _) = await SeedAsync();
using var _ = ctx;
var foreignId = Guid.NewGuid().ToString();
var api = new FakeApi
{
UnimportedTasks =
[
new RemoteTask(foreignId, listId, "Theirs", null, DateTimeOffset.UtcNow, OwnerId: "owner-2"),
],
};
var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
await svc.TickAsync(CancellationToken.None);
Assert.Null(await tasks.GetByIdAsync(foreignId));
Assert.DoesNotContain(foreignId, api.MarkedImported);
}
[Fact]
public async Task Tick_Imports_OwnTask_And_UnownedTask()
{
var (listId, ctx, tasks, _) = await SeedAsync();
using var _ = ctx;
var mine = Guid.NewGuid().ToString();
var unowned = Guid.NewGuid().ToString();
var api = new FakeApi
{
UnimportedTasks =
[
new RemoteTask(mine, listId, "Mine", null, DateTimeOffset.UtcNow, OwnerId: "owner-1"),
new RemoteTask(unowned, listId, "Unowned", null, DateTimeOffset.UtcNow),
],
};
var svc = BuildService(api, JwtClaimsTests.MakeToken("{\"sub\":\"owner-1\"}"));
await svc.TickAsync(CancellationToken.None);
Assert.NotNull(await tasks.GetByIdAsync(mine));
Assert.NotNull(await tasks.GetByIdAsync(unowned));
}
// ---- already-imported task on server ----
[Fact]
public async Task Tick_AlreadyLocalTask_MarksImportedWithoutDuplicate()
{
var (listId, ctx, tasks, _) = await SeedAsync();
using var _ = ctx;
var existingId = Guid.NewGuid().ToString();
var existing = new TaskEntity
{
Id = existingId, ListId = listId, Title = "Existing",
Status = TaskStatus.Idle, CreatedAt = DateTime.UtcNow,
};
await tasks.AddAsync(existing);
var api = new FakeApi
{
// Server thinks this task isn't imported yet (e.g. a retry scenario)
UnimportedTasks = [new RemoteTask(existingId, listId, "Existing", null, DateTimeOffset.UtcNow)],
};
var svc = BuildService(api);
await svc.TickAsync(CancellationToken.None);
// Should still mark imported, and not create a duplicate
Assert.Contains(existingId, api.MarkedImported);
using var ctx2 = _db.CreateContext();
var count = (await new TaskRepository(ctx2).GetByListIdAsync(listId)).Count(t => t.Id == existingId);
Assert.Equal(1, count);
}
}