OnlineSyncService is registered once at startup; toggling the feature off in Settings persisted the flag but never stopped the running loop, so it kept polling and failing OIDC discovery every cycle. Guard TickAsync on the shared config's Enabled flag so disabling takes effect live.
325 lines
11 KiB
C#
325 lines
11 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", bool enabled = true)
|
|
{
|
|
var config = new OnlineInboxConfig { Enabled = enabled, 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);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tick_Disabled_SkipsCycle_NoApiCalls()
|
|
{
|
|
_ = await SeedAsync();
|
|
var api = new FakeApi();
|
|
var svc = BuildService(api, enabled: false);
|
|
|
|
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);
|
|
}
|
|
}
|