Optional, opt-in (online_inbox.enabled, default false → zero network). Worker-side reconcile loop: pull web-created tasks down as Idle, push the list catalog and the Idle backlog mirror up. Auth behind IOnlineAuthProvider (StaticTokenAuthProvider default; ZitadelAuthProvider stubbed for Phase 2). DPAPI refresh-token store. 35 tests, no real network/Zitadel/Claude. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
243 lines
8.0 KiB
C#
243 lines
8.0 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// Integration tests for <see cref="OnlineSyncService"/> using a fake API + real SQLite.
|
|
/// </summary>
|
|
public sealed class OnlineSyncServiceTests : IDisposable
|
|
{
|
|
private readonly DbFixture _db = new();
|
|
|
|
public void Dispose() => _db.Dispose();
|
|
|
|
// ---- fake API ----
|
|
|
|
private sealed class FakeApi : IOnlineInboxApi
|
|
{
|
|
public List<RemoteTask> UnimportedTasks { get; set; } = [];
|
|
public List<RemoteList> ReceivedLists { get; } = [];
|
|
public List<MirrorTask> ReceivedMirror { get; } = [];
|
|
public List<string> MarkedImported { get; } = [];
|
|
public int CallCount { get; private set; }
|
|
|
|
public Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default)
|
|
{
|
|
CallCount++;
|
|
ReceivedLists.AddRange(lists);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default)
|
|
{
|
|
CallCount++;
|
|
return Task.FromResult<IReadOnlyList<RemoteTask>>(UnimportedTasks);
|
|
}
|
|
|
|
public Task MarkImportedAsync(string id, CancellationToken ct = default)
|
|
{
|
|
CallCount++;
|
|
MarkedImported.Add(id);
|
|
return Task.CompletedTask;
|
|
}
|
|
|
|
public Task PutMirrorAsync(IReadOnlyList<MirrorTask> 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<OnlineSyncService>.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);
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
}
|