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); } }