Plumb a per-resource owner (Zitadel sub) through the sync contract without enforcing isolation client-side — the server stays the authority. - Dtos: add optional ownerId to RemoteList/RemoteTask/MirrorTask - JwtClaims: decode the sub claim from the access token (never throws) - OnlineSyncService: stamp ownerId on pushed lists + mirror; defensively skip pulled tasks owned by a different user (unowned tasks still sync, so single-user behavior is unchanged) - docs: contract documents ownerId + multi-user readiness Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
313 lines
10 KiB
C#
313 lines
10 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);
|
|
}
|
|
|
|
// ---- 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);
|
|
}
|
|
}
|