feat(worker): Online Inbox sync engine (Phase 1)
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>
This commit is contained in:
@@ -87,6 +87,22 @@ public sealed class TaskRepository
|
|||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns all tasks that qualify as "real" Idle backlog items for online mirroring:
|
||||||
|
/// Status==Idle, no parent, PlanningPhase==None, not blocked.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<List<TaskEntity>> GetAllIdleBacklogAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
return await _context.Tasks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(t => t.Status == TaskStatus.Idle
|
||||||
|
&& t.ParentTaskId == null
|
||||||
|
&& t.PlanningPhase == PlanningPhase.None
|
||||||
|
&& t.BlockedByTaskId == null)
|
||||||
|
.OrderBy(t => t.SortOrder).ThenBy(t => t.CreatedAt)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Status transitions
|
#region Status transitions
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ Worker/
|
|||||||
Hub/ — WorkerHub, HubBroadcaster
|
Hub/ — WorkerHub, HubBroadcaster
|
||||||
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
|
Report/ — ClaudeHistoryReader, WeekReportPromptBuilder, WeekReportService; interfaces in Report/Interfaces/
|
||||||
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
|
Prime/ — daily-prep ("Prime Claude"): PrimeScheduler (BackgroundService), PrimeRunner (runs the daily prep), DailyPrepPrompt (fixed prompt + CLI args + LogPath() helper), NextDueCalculator, PrimeScheduleSignal; interfaces in Prime/Interfaces/ (IPrimeRunner, IPrimeClock, IPrimeScheduleSignal, IPrimeBroadcaster)
|
||||||
|
Online/ — optional Online Inbox sync: OnlineInboxConfig (config record), Dtos (RemoteList/RemoteTask/MirrorTask), IOnlineInboxApi, OnlineInboxApiClient (typed HttpClient, bearer auth, HTTPS guard), OnlineTokenStore (DPAPI refresh-token store, Windows-only), StaticTokenAuthProvider (default/test IOnlineAuthProvider), ZitadelAuthProvider (stub — TODO(online-inbox) Phase 2), OnlineSyncService (BackgroundService: reconcile loop), OnlineBacklog (Idle-backlog filter/query); interface in Online/Interfaces/ (IOnlineAuthProvider)
|
||||||
```
|
```
|
||||||
|
|
||||||
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
|
Interfaces (e.g. `IQueueWaker`, `IPrimeClock`, `ITaskStateService`) live in an `Interfaces/` subfolder within their area; the namespace stays the area namespace.
|
||||||
@@ -165,6 +166,12 @@ Loaded from `~/.todo-app/worker.config.json`:
|
|||||||
- `queue_backstop_interval_ms` (default 30000)
|
- `queue_backstop_interval_ms` (default 30000)
|
||||||
- `signalr_port` (default 47821)
|
- `signalr_port` (default 47821)
|
||||||
- `claude_bin` (path to claude CLI)
|
- `claude_bin` (path to claude CLI)
|
||||||
|
- `online_inbox` — Online Inbox config (default: `enabled=false`, zero network when disabled):
|
||||||
|
- `enabled` (bool, default false) — when false the entire `Online/` stack is not registered
|
||||||
|
- `api_base_url` (string) — must be HTTPS or loopback; validated at startup when enabled
|
||||||
|
- `poll_interval_seconds` (int, default 60)
|
||||||
|
- `zitadel.authority`, `zitadel.client_id`, `zitadel.scopes` (Phase 2; not used until ZitadelAuthProvider is wired)
|
||||||
|
- The refresh token is NOT in this file — stored encrypted via DPAPI at `~/.todo-app/online-inbox.token`
|
||||||
|
|
||||||
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order).
|
Per-list config (`list_config` in DB) provides defaults for `model`, `system_prompt`, `agent_path`; tasks can override each individually. Task-generating MCP tools (`AddTask`, planning `CreateChildTask`, `SuggestImprovement`) accept an optional `model` (alias-validated via `ModelRegistry.NormalizeAlias` — `haiku`/`sonnet`/`opus`, blank = inherit) so Claude assigns the cheapest capable model at creation time; the planning/system/improvement prompts instruct it to do so (`ModelRegistry.ByCostAscending` = the cost order).
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
|
||||||
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol" Version="1.2.0" />
|
||||||
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
<PackageReference Include="ModelContextProtocol.AspNetCore" Version="1.2.0" />
|
||||||
|
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="8.0.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using ClaudeDo.Data;
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
namespace ClaudeDo.Worker.Config;
|
namespace ClaudeDo.Worker.Config;
|
||||||
|
|
||||||
@@ -39,6 +40,9 @@ public sealed class WorkerConfig
|
|||||||
[JsonPropertyName("external_mcp_api_key")]
|
[JsonPropertyName("external_mcp_api_key")]
|
||||||
public string? ExternalMcpApiKey { get; set; }
|
public string? ExternalMcpApiKey { get; set; }
|
||||||
|
|
||||||
|
[JsonPropertyName("online_inbox")]
|
||||||
|
public OnlineInboxConfig OnlineInbox { get; set; } = new();
|
||||||
|
|
||||||
public static string DefaultConfigPath =>
|
public static string DefaultConfigPath =>
|
||||||
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
Path.Combine(Paths.AppDataRoot(), "worker.config.json");
|
||||||
|
|
||||||
|
|||||||
20
src/ClaudeDo.Worker/Online/Dtos.cs
Normal file
20
src/ClaudeDo.Worker/Online/Dtos.cs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public sealed record RemoteList(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("name")] string Name);
|
||||||
|
|
||||||
|
public sealed record RemoteTask(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("listId")] string ListId,
|
||||||
|
[property: JsonPropertyName("title")] string Title,
|
||||||
|
[property: JsonPropertyName("description")] string? Description,
|
||||||
|
[property: JsonPropertyName("createdAt")] DateTimeOffset CreatedAt);
|
||||||
|
|
||||||
|
public sealed record MirrorTask(
|
||||||
|
[property: JsonPropertyName("id")] string Id,
|
||||||
|
[property: JsonPropertyName("listId")] string ListId,
|
||||||
|
[property: JsonPropertyName("title")] string Title,
|
||||||
|
[property: JsonPropertyName("description")] string? Description);
|
||||||
9
src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs
Normal file
9
src/ClaudeDo.Worker/Online/IOnlineInboxApi.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public interface IOnlineInboxApi
|
||||||
|
{
|
||||||
|
Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default);
|
||||||
|
Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default);
|
||||||
|
Task MarkImportedAsync(string id, CancellationToken ct = default);
|
||||||
|
Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
|
||||||
|
public interface IOnlineAuthProvider
|
||||||
|
{
|
||||||
|
Task<string?> GetAccessTokenAsync(CancellationToken ct = default);
|
||||||
|
}
|
||||||
27
src/ClaudeDo.Worker/Online/OnlineBacklog.cs
Normal file
27
src/ClaudeDo.Worker/Online/OnlineBacklog.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public static class OnlineBacklog
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the current Idle backlog: Status==Idle, no parent, PlanningPhase==None, not blocked.
|
||||||
|
/// These are the tasks mirrored to the online store (§2 of the contract).
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<List<MirrorTask>> CurrentAsync(
|
||||||
|
TaskRepository tasks,
|
||||||
|
CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var all = await tasks.GetAllIdleBacklogAsync(ct);
|
||||||
|
return all.Select(t => new MirrorTask(t.Id, t.ListId, t.Title, t.Description)).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static bool IsBacklogItem(TaskEntity t) =>
|
||||||
|
t.Status == TaskStatus.Idle
|
||||||
|
&& t.ParentTaskId == null
|
||||||
|
&& t.PlanningPhase == PlanningPhase.None
|
||||||
|
&& t.BlockedByTaskId == null;
|
||||||
|
}
|
||||||
84
src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs
Normal file
84
src/ClaudeDo.Worker/Online/OnlineInboxApiClient.cs
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
using System.Net.Http.Json;
|
||||||
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public sealed class OnlineInboxApiClient : IOnlineInboxApi
|
||||||
|
{
|
||||||
|
private readonly HttpClient _http;
|
||||||
|
private readonly IOnlineAuthProvider _auth;
|
||||||
|
|
||||||
|
public OnlineInboxApiClient(HttpClient http, IOnlineAuthProvider auth)
|
||||||
|
{
|
||||||
|
_http = http;
|
||||||
|
_auth = auth;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates that <paramref name="baseUrl"/> is HTTPS or a loopback address.
|
||||||
|
/// Throws <see cref="InvalidOperationException"/> for non-HTTPS non-loopback URLs.
|
||||||
|
/// </summary>
|
||||||
|
public static void ValidateBaseUrl(string baseUrl)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||||
|
throw new InvalidOperationException("online_inbox.api_base_url is not configured.");
|
||||||
|
|
||||||
|
var uri = new Uri(baseUrl, UriKind.Absolute);
|
||||||
|
if (uri.Scheme != "https" && !uri.IsLoopback)
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"online_inbox.api_base_url must be HTTPS or loopback. Got: {baseUrl}");
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PutListsAsync(IReadOnlyList<RemoteList> lists, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var req = await BuildAsync(HttpMethod.Put, "/lists", lists, ct);
|
||||||
|
using var resp = await _http.SendAsync(req, ct);
|
||||||
|
await EnsureSuccessAsync(resp, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<RemoteTask>> GetUnimportedTasksAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var req = await BuildAsync(HttpMethod.Get, "/tasks?imported=false", null, ct);
|
||||||
|
using var resp = await _http.SendAsync(req, ct);
|
||||||
|
await EnsureSuccessAsync(resp, ct);
|
||||||
|
var result = await resp.Content.ReadFromJsonAsync<List<RemoteTask>>(ct);
|
||||||
|
return result ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkImportedAsync(string id, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var req = await BuildAsync(HttpMethod.Post, $"/tasks/{Uri.EscapeDataString(id)}/imported", null, ct);
|
||||||
|
using var resp = await _http.SendAsync(req, ct);
|
||||||
|
await EnsureSuccessAsync(resp, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task PutMirrorAsync(IReadOnlyList<MirrorTask> tasks, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
using var req = await BuildAsync(HttpMethod.Put, "/tasks/mirror", tasks, ct);
|
||||||
|
using var resp = await _http.SendAsync(req, ct);
|
||||||
|
await EnsureSuccessAsync(resp, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<HttpRequestMessage> BuildAsync(
|
||||||
|
HttpMethod method,
|
||||||
|
string path,
|
||||||
|
object? body,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = await _auth.GetAccessTokenAsync(ct);
|
||||||
|
var req = new HttpRequestMessage(method, path);
|
||||||
|
if (token is not null)
|
||||||
|
req.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", token);
|
||||||
|
if (body is not null)
|
||||||
|
req.Content = JsonContent.Create(body);
|
||||||
|
return req;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task EnsureSuccessAsync(HttpResponseMessage resp, CancellationToken ct)
|
||||||
|
{
|
||||||
|
if (resp.IsSuccessStatusCode) return;
|
||||||
|
var body = await resp.Content.ReadAsStringAsync(ct);
|
||||||
|
throw new OnlineInboxException((int)resp.StatusCode,
|
||||||
|
$"Online Inbox API error {(int)resp.StatusCode}: {body}");
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs
Normal file
30
src/ClaudeDo.Worker/Online/OnlineInboxConfig.cs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public sealed class ZitadelClientConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("authority")]
|
||||||
|
public string Authority { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("client_id")]
|
||||||
|
public string ClientId { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("scopes")]
|
||||||
|
public string Scopes { get; set; } = "openid offline_access";
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class OnlineInboxConfig
|
||||||
|
{
|
||||||
|
[JsonPropertyName("enabled")]
|
||||||
|
public bool Enabled { get; set; } = false;
|
||||||
|
|
||||||
|
[JsonPropertyName("api_base_url")]
|
||||||
|
public string ApiBaseUrl { get; set; } = "";
|
||||||
|
|
||||||
|
[JsonPropertyName("poll_interval_seconds")]
|
||||||
|
public int PollIntervalSeconds { get; set; } = 60;
|
||||||
|
|
||||||
|
[JsonPropertyName("zitadel")]
|
||||||
|
public ZitadelClientConfig Zitadel { get; set; } = new();
|
||||||
|
}
|
||||||
12
src/ClaudeDo.Worker/Online/OnlineInboxException.cs
Normal file
12
src/ClaudeDo.Worker/Online/OnlineInboxException.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public sealed class OnlineInboxException : Exception
|
||||||
|
{
|
||||||
|
public int StatusCode { get; }
|
||||||
|
|
||||||
|
public OnlineInboxException(int statusCode, string message)
|
||||||
|
: base(message)
|
||||||
|
{
|
||||||
|
StatusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/ClaudeDo.Worker/Online/OnlineSyncService.cs
Normal file
121
src/ClaudeDo.Worker/Online/OnlineSyncService.cs
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
public sealed class OnlineSyncService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IDbContextFactory<ClaudeDoDbContext> _dbFactory;
|
||||||
|
private readonly IOnlineInboxApi _api;
|
||||||
|
private readonly IOnlineAuthProvider _auth;
|
||||||
|
private readonly OnlineInboxConfig _config;
|
||||||
|
private readonly ILogger<OnlineSyncService> _logger;
|
||||||
|
|
||||||
|
public OnlineSyncService(
|
||||||
|
IDbContextFactory<ClaudeDoDbContext> dbFactory,
|
||||||
|
IOnlineInboxApi api,
|
||||||
|
IOnlineAuthProvider auth,
|
||||||
|
OnlineInboxConfig config,
|
||||||
|
ILogger<OnlineSyncService> logger)
|
||||||
|
{
|
||||||
|
_dbFactory = dbFactory;
|
||||||
|
_api = api;
|
||||||
|
_auth = auth;
|
||||||
|
_config = config;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await TickAsync(stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "OnlineSyncService cycle failed; backing off to next interval");
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(_config.PollIntervalSeconds), stoppingToken);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal async Task TickAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
var token = await _auth.GetAccessTokenAsync(ct);
|
||||||
|
if (token is null)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("OnlineSyncService: no access token, skipping cycle");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using var ctx = await _dbFactory.CreateDbContextAsync(ct);
|
||||||
|
var tasks = new TaskRepository(ctx);
|
||||||
|
var lists = new ListRepository(ctx);
|
||||||
|
|
||||||
|
// Step 1: pull unimported tasks, import them locally, mark each imported.
|
||||||
|
var unimported = await _api.GetUnimportedTasksAsync(ct);
|
||||||
|
foreach (var remote in unimported)
|
||||||
|
{
|
||||||
|
var existing = await tasks.GetByIdAsync(remote.Id, ct);
|
||||||
|
if (existing is not null)
|
||||||
|
{
|
||||||
|
// Already imported locally; just mark it on the server.
|
||||||
|
await _api.MarkImportedAsync(remote.Id, ct);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var list = await lists.GetByIdAsync(remote.ListId, ct);
|
||||||
|
if (list is null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(
|
||||||
|
"OnlineSyncService: remote task {Id} references unknown list {ListId}; skipping",
|
||||||
|
remote.Id, remote.ListId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entity = new TaskEntity
|
||||||
|
{
|
||||||
|
Id = remote.Id,
|
||||||
|
ListId = remote.ListId,
|
||||||
|
Title = remote.Title,
|
||||||
|
Description = remote.Description,
|
||||||
|
Status = TaskStatus.Idle,
|
||||||
|
CreatedBy = "online",
|
||||||
|
CreatedAt = remote.CreatedAt.UtcDateTime,
|
||||||
|
CommitType = CommitTypeRegistry.DefaultType,
|
||||||
|
};
|
||||||
|
await tasks.AddAsync(entity, ct);
|
||||||
|
await _api.MarkImportedAsync(remote.Id, ct);
|
||||||
|
|
||||||
|
_logger.LogInformation("OnlineSyncService: imported task {Id} ('{Title}')", remote.Id, remote.Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: push full list catalog.
|
||||||
|
var allLists = await lists.GetAllAsync(ct);
|
||||||
|
var remoteLists = allLists.Select(l => new RemoteList(l.Id, l.Name)).ToList();
|
||||||
|
await _api.PutListsAsync(remoteLists, ct);
|
||||||
|
|
||||||
|
// Step 3: push current Idle backlog mirror.
|
||||||
|
var mirror = await OnlineBacklog.CurrentAsync(tasks, ct);
|
||||||
|
await _api.PutMirrorAsync(mirror, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/ClaudeDo.Worker/Online/OnlineTokenStore.cs
Normal file
54
src/ClaudeDo.Worker/Online/OnlineTokenStore.cs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
using System.Runtime.Versioning;
|
||||||
|
using System.Security.Cryptography;
|
||||||
|
using System.Text;
|
||||||
|
using ClaudeDo.Data;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Persists the Zitadel refresh token encrypted with DPAPI (CurrentUser scope).
|
||||||
|
/// Windows-only; the file lives at ~/.todo-app/online-inbox.token.
|
||||||
|
/// </summary>
|
||||||
|
[SupportedOSPlatform("windows")]
|
||||||
|
public sealed class OnlineTokenStore
|
||||||
|
{
|
||||||
|
private readonly string _tokenPath;
|
||||||
|
|
||||||
|
public OnlineTokenStore()
|
||||||
|
: this(Path.Combine(Paths.AppDataRoot(), "online-inbox.token")) { }
|
||||||
|
|
||||||
|
internal OnlineTokenStore(string tokenPath)
|
||||||
|
{
|
||||||
|
_tokenPath = tokenPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save(string refreshToken)
|
||||||
|
{
|
||||||
|
ArgumentException.ThrowIfNullOrEmpty(refreshToken);
|
||||||
|
var plain = Encoding.UTF8.GetBytes(refreshToken);
|
||||||
|
var cipher = ProtectedData.Protect(plain, null, DataProtectionScope.CurrentUser);
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(_tokenPath)!);
|
||||||
|
File.WriteAllBytes(_tokenPath, cipher);
|
||||||
|
}
|
||||||
|
|
||||||
|
public string? Read()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_tokenPath)) return null;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var cipher = File.ReadAllBytes(_tokenPath);
|
||||||
|
var plain = ProtectedData.Unprotect(cipher, null, DataProtectionScope.CurrentUser);
|
||||||
|
return Encoding.UTF8.GetString(plain);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Clear()
|
||||||
|
{
|
||||||
|
if (File.Exists(_tokenPath))
|
||||||
|
File.Delete(_tokenPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs
Normal file
21
src/ClaudeDo.Worker/Online/StaticTokenAuthProvider.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Simple <see cref="IOnlineAuthProvider"/> that returns a fixed token supplied at construction.
|
||||||
|
/// Used as the default DI registration until <c>ZitadelAuthProvider</c> is wired (Phase 2).
|
||||||
|
/// Also serves as the test double.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class StaticTokenAuthProvider : IOnlineAuthProvider
|
||||||
|
{
|
||||||
|
private readonly string? _token;
|
||||||
|
|
||||||
|
public StaticTokenAuthProvider(string? token = null)
|
||||||
|
{
|
||||||
|
_token = token;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult(_token);
|
||||||
|
}
|
||||||
13
src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs
Normal file
13
src/ClaudeDo.Worker/Online/ZitadelAuthProvider.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
// TODO(online-inbox): wire the Zitadel package once client config is known (Phase 2).
|
||||||
|
// Replace this stub with a real implementation that uses OnlineTokenStore to read the
|
||||||
|
// refresh token and exchanges it for an access token via the Zitadel OIDC endpoint,
|
||||||
|
// caching the access token until near expiry.
|
||||||
|
public sealed class ZitadelAuthProvider : IOnlineAuthProvider
|
||||||
|
{
|
||||||
|
public Task<string?> GetAccessTokenAsync(CancellationToken ct = default)
|
||||||
|
=> Task.FromResult<string?>(null);
|
||||||
|
}
|
||||||
@@ -11,6 +11,8 @@ using ClaudeDo.Worker.Planning;
|
|||||||
using ClaudeDo.Worker.Queue;
|
using ClaudeDo.Worker.Queue;
|
||||||
using ClaudeDo.Worker.Runner;
|
using ClaudeDo.Worker.Runner;
|
||||||
using ClaudeDo.Worker.State;
|
using ClaudeDo.Worker.State;
|
||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
using ClaudeDo.Worker.Online.Interfaces;
|
||||||
using ClaudeDo.Worker.Prime;
|
using ClaudeDo.Worker.Prime;
|
||||||
using ClaudeDo.Worker.Refine;
|
using ClaudeDo.Worker.Refine;
|
||||||
using ClaudeDo.Worker.Report;
|
using ClaudeDo.Worker.Report;
|
||||||
@@ -149,6 +151,22 @@ builder.Services.AddMcpServer()
|
|||||||
.WithTools<PlanningMcpService>()
|
.WithTools<PlanningMcpService>()
|
||||||
.WithTools<TaskRunMcpService>();
|
.WithTools<TaskRunMcpService>();
|
||||||
|
|
||||||
|
// Online Inbox — registered only when enabled.
|
||||||
|
if (cfg.OnlineInbox.Enabled)
|
||||||
|
{
|
||||||
|
OnlineInboxApiClient.ValidateBaseUrl(cfg.OnlineInbox.ApiBaseUrl);
|
||||||
|
builder.Services.AddSingleton(cfg.OnlineInbox);
|
||||||
|
builder.Services.AddSingleton<IOnlineAuthProvider, StaticTokenAuthProvider>();
|
||||||
|
#pragma warning disable CA1416 // ClaudeDo.Worker is Windows-only; DPAPI is fine here.
|
||||||
|
builder.Services.AddSingleton<OnlineTokenStore>();
|
||||||
|
#pragma warning restore CA1416
|
||||||
|
builder.Services.AddHttpClient<IOnlineInboxApi, OnlineInboxApiClient>(client =>
|
||||||
|
{
|
||||||
|
client.BaseAddress = new Uri(cfg.OnlineInbox.ApiBaseUrl.TrimEnd('/') + "/");
|
||||||
|
});
|
||||||
|
builder.Services.AddHostedService<OnlineSyncService>();
|
||||||
|
}
|
||||||
|
|
||||||
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
// Loopback-only bind. Firewall is irrelevant for 127.0.0.1.
|
||||||
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
builder.WebHost.UseUrls($"http://127.0.0.1:{cfg.SignalRPort}");
|
||||||
|
|
||||||
|
|||||||
167
tests/ClaudeDo.Worker.Tests/Online/OnlineBacklogTests.cs
Normal file
167
tests/ClaudeDo.Worker.Tests/Online/OnlineBacklogTests.cs
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
using ClaudeDo.Data;
|
||||||
|
using ClaudeDo.Data.Models;
|
||||||
|
using ClaudeDo.Data.Repositories;
|
||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
using ClaudeDo.Worker.Tests.Infrastructure;
|
||||||
|
using TaskStatus = ClaudeDo.Data.Models.TaskStatus;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Online;
|
||||||
|
|
||||||
|
public sealed class OnlineBacklogTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly DbFixture _db = new();
|
||||||
|
private readonly ClaudeDoDbContext _ctx;
|
||||||
|
private readonly TaskRepository _tasks;
|
||||||
|
private readonly ListRepository _lists;
|
||||||
|
private string _listId = null!;
|
||||||
|
|
||||||
|
public OnlineBacklogTests()
|
||||||
|
{
|
||||||
|
_ctx = _db.CreateContext();
|
||||||
|
_tasks = new TaskRepository(_ctx);
|
||||||
|
_lists = new ListRepository(_ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
_ctx.Dispose();
|
||||||
|
_db.Dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SeedListAsync()
|
||||||
|
{
|
||||||
|
_listId = Guid.NewGuid().ToString();
|
||||||
|
await _lists.AddAsync(new ListEntity { Id = _listId, Name = "Test", CreatedAt = DateTime.UtcNow });
|
||||||
|
}
|
||||||
|
|
||||||
|
private TaskEntity Make(
|
||||||
|
TaskStatus status = TaskStatus.Idle,
|
||||||
|
string? parentId = null,
|
||||||
|
PlanningPhase planning = PlanningPhase.None,
|
||||||
|
string? blockedById = null) => new()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid().ToString(),
|
||||||
|
ListId = _listId,
|
||||||
|
Title = "T",
|
||||||
|
Status = status,
|
||||||
|
ParentTaskId = parentId,
|
||||||
|
PlanningPhase = planning,
|
||||||
|
BlockedByTaskId = blockedById,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
};
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CurrentAsync_Returns_OnlyIdleBacklogItems()
|
||||||
|
{
|
||||||
|
await SeedListAsync();
|
||||||
|
|
||||||
|
var idle = Make(TaskStatus.Idle);
|
||||||
|
var queued = Make(TaskStatus.Queued);
|
||||||
|
var running = Make(TaskStatus.Running);
|
||||||
|
var done = Make(TaskStatus.Done);
|
||||||
|
var failed = Make(TaskStatus.Failed);
|
||||||
|
|
||||||
|
// Idle but with parent → planning child
|
||||||
|
var parent = Make(TaskStatus.Idle);
|
||||||
|
await _tasks.AddAsync(parent);
|
||||||
|
var child = Make(TaskStatus.Idle, parentId: parent.Id);
|
||||||
|
|
||||||
|
// Idle but with PlanningPhase
|
||||||
|
var planningParent = Make(TaskStatus.Idle, planning: PlanningPhase.Active);
|
||||||
|
|
||||||
|
// Idle but blocked
|
||||||
|
await _tasks.AddAsync(idle);
|
||||||
|
var blocker = Make(TaskStatus.Idle);
|
||||||
|
await _tasks.AddAsync(blocker);
|
||||||
|
var blocked = Make(TaskStatus.Idle, blockedById: blocker.Id);
|
||||||
|
|
||||||
|
await _tasks.AddAsync(queued);
|
||||||
|
await _tasks.AddAsync(running);
|
||||||
|
await _tasks.AddAsync(done);
|
||||||
|
await _tasks.AddAsync(failed);
|
||||||
|
await _tasks.AddAsync(child);
|
||||||
|
await _tasks.AddAsync(planningParent);
|
||||||
|
await _tasks.AddAsync(blocked);
|
||||||
|
|
||||||
|
var mirror = await OnlineBacklog.CurrentAsync(_tasks);
|
||||||
|
|
||||||
|
// Only the plain idle task and the blocker (which itself is plain idle) should appear
|
||||||
|
var ids = mirror.Select(m => m.Id).ToHashSet();
|
||||||
|
Assert.Contains(idle.Id, ids);
|
||||||
|
Assert.Contains(blocker.Id, ids);
|
||||||
|
Assert.Contains(parent.Id, ids); // parent with no parent itself is a backlog item
|
||||||
|
|
||||||
|
Assert.DoesNotContain(queued.Id, ids);
|
||||||
|
Assert.DoesNotContain(running.Id, ids);
|
||||||
|
Assert.DoesNotContain(done.Id, ids);
|
||||||
|
Assert.DoesNotContain(failed.Id, ids);
|
||||||
|
Assert.DoesNotContain(child.Id, ids);
|
||||||
|
Assert.DoesNotContain(planningParent.Id, ids);
|
||||||
|
Assert.DoesNotContain(blocked.Id, ids);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void IsBacklogItem_Predicate_FiltersCorrectly()
|
||||||
|
{
|
||||||
|
Assert.True(OnlineBacklog.IsBacklogItem(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "1", ListId = "l", Title = "T",
|
||||||
|
Status = TaskStatus.Idle, ParentTaskId = null,
|
||||||
|
PlanningPhase = PlanningPhase.None, BlockedByTaskId = null,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}));
|
||||||
|
Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "2", ListId = "l", Title = "T",
|
||||||
|
Status = TaskStatus.Queued, ParentTaskId = null,
|
||||||
|
PlanningPhase = PlanningPhase.None, BlockedByTaskId = null,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}));
|
||||||
|
Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "3", ListId = "l", Title = "T",
|
||||||
|
Status = TaskStatus.Idle, ParentTaskId = "p",
|
||||||
|
PlanningPhase = PlanningPhase.None, BlockedByTaskId = null,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}));
|
||||||
|
Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "4", ListId = "l", Title = "T",
|
||||||
|
Status = TaskStatus.Idle, ParentTaskId = null,
|
||||||
|
PlanningPhase = PlanningPhase.Active, BlockedByTaskId = null,
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}));
|
||||||
|
Assert.False(OnlineBacklog.IsBacklogItem(new TaskEntity
|
||||||
|
{
|
||||||
|
Id = "5", ListId = "l", Title = "T",
|
||||||
|
Status = TaskStatus.Idle, ParentTaskId = null,
|
||||||
|
PlanningPhase = PlanningPhase.None, BlockedByTaskId = "b",
|
||||||
|
CreatedAt = DateTime.UtcNow,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CurrentAsync_EmptyDb_ReturnsEmpty()
|
||||||
|
{
|
||||||
|
await SeedListAsync();
|
||||||
|
var mirror = await OnlineBacklog.CurrentAsync(_tasks);
|
||||||
|
Assert.Empty(mirror);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task CurrentAsync_MapsFieldsCorrectly()
|
||||||
|
{
|
||||||
|
await SeedListAsync();
|
||||||
|
var task = Make(TaskStatus.Idle);
|
||||||
|
task.Description = "desc";
|
||||||
|
await _tasks.AddAsync(task);
|
||||||
|
|
||||||
|
var mirror = await OnlineBacklog.CurrentAsync(_tasks);
|
||||||
|
|
||||||
|
Assert.Single(mirror);
|
||||||
|
Assert.Equal(task.Id, mirror[0].Id);
|
||||||
|
Assert.Equal(_listId, mirror[0].ListId);
|
||||||
|
Assert.Equal("T", mirror[0].Title);
|
||||||
|
Assert.Equal("desc", mirror[0].Description);
|
||||||
|
}
|
||||||
|
}
|
||||||
183
tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs
Normal file
183
tests/ClaudeDo.Worker.Tests/Online/OnlineInboxApiClientTests.cs
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Online;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tests for <see cref="OnlineInboxApiClient"/> using a stubbed <see cref="HttpMessageHandler"/>.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class OnlineInboxApiClientTests
|
||||||
|
{
|
||||||
|
private sealed class StubHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
public List<HttpRequestMessage> Requests { get; } = new();
|
||||||
|
public HttpStatusCode ResponseStatus { get; set; } = HttpStatusCode.OK;
|
||||||
|
public string ResponseBody { get; set; } = "[]";
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken ct)
|
||||||
|
{
|
||||||
|
Requests.Add(request);
|
||||||
|
var resp = new HttpResponseMessage(ResponseStatus)
|
||||||
|
{
|
||||||
|
Content = new StringContent(ResponseBody, Encoding.UTF8, "application/json"),
|
||||||
|
};
|
||||||
|
return Task.FromResult(resp);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (OnlineInboxApiClient Client, StubHandler Handler) Build(string? token = "test-token")
|
||||||
|
{
|
||||||
|
var handler = new StubHandler();
|
||||||
|
var http = new HttpClient(handler) { BaseAddress = new Uri("https://inbox.example.com/") };
|
||||||
|
var auth = new StaticTokenAuthProvider(token);
|
||||||
|
return (new OnlineInboxApiClient(http, auth), handler);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PutListsAsync ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PutListsAsync_UsesCorrectVerbAndPath()
|
||||||
|
{
|
||||||
|
var (client, handler) = Build();
|
||||||
|
await client.PutListsAsync([new RemoteList("id1", "List 1")]);
|
||||||
|
|
||||||
|
Assert.Single(handler.Requests);
|
||||||
|
Assert.Equal(HttpMethod.Put, handler.Requests[0].Method);
|
||||||
|
Assert.Equal("/lists", handler.Requests[0].RequestUri!.AbsolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PutListsAsync_AttachesBearerToken()
|
||||||
|
{
|
||||||
|
var (client, handler) = Build("my-bearer");
|
||||||
|
await client.PutListsAsync([]);
|
||||||
|
|
||||||
|
Assert.Equal("Bearer", handler.Requests[0].Headers.Authorization!.Scheme);
|
||||||
|
Assert.Equal("my-bearer", handler.Requests[0].Headers.Authorization!.Parameter);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- GetUnimportedTasksAsync ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUnimportedTasksAsync_UsesGetWithQueryParam()
|
||||||
|
{
|
||||||
|
var (client, handler) = Build();
|
||||||
|
handler.ResponseBody = "[]";
|
||||||
|
await client.GetUnimportedTasksAsync();
|
||||||
|
|
||||||
|
Assert.Equal(HttpMethod.Get, handler.Requests[0].Method);
|
||||||
|
Assert.Contains("imported=false", handler.Requests[0].RequestUri!.Query);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetUnimportedTasksAsync_DeserializesResponse()
|
||||||
|
{
|
||||||
|
var (client, handler) = Build();
|
||||||
|
var tasks = new[]
|
||||||
|
{
|
||||||
|
new { id = "t1", listId = "l1", title = "Title", description = (string?)null, createdAt = DateTimeOffset.UtcNow },
|
||||||
|
};
|
||||||
|
handler.ResponseBody = JsonSerializer.Serialize(tasks);
|
||||||
|
|
||||||
|
var result = await client.GetUnimportedTasksAsync();
|
||||||
|
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("t1", result[0].Id);
|
||||||
|
Assert.Equal("l1", result[0].ListId);
|
||||||
|
Assert.Equal("Title", result[0].Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- MarkImportedAsync ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task MarkImportedAsync_UsesPostAndCorrectPath()
|
||||||
|
{
|
||||||
|
var (client, handler) = Build();
|
||||||
|
await client.MarkImportedAsync("task-id-123");
|
||||||
|
|
||||||
|
Assert.Equal(HttpMethod.Post, handler.Requests[0].Method);
|
||||||
|
Assert.Equal("/tasks/task-id-123/imported", handler.Requests[0].RequestUri!.AbsolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- PutMirrorAsync ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task PutMirrorAsync_UsesPutAndCorrectPath()
|
||||||
|
{
|
||||||
|
var (client, handler) = Build();
|
||||||
|
await client.PutMirrorAsync([new MirrorTask("id1", "l1", "T", null)]);
|
||||||
|
|
||||||
|
Assert.Equal(HttpMethod.Put, handler.Requests[0].Method);
|
||||||
|
Assert.Equal("/tasks/mirror", handler.Requests[0].RequestUri!.AbsolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 401 handling ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NonSuccessResponse_Throws_OnlineInboxException()
|
||||||
|
{
|
||||||
|
var (client, handler) = Build();
|
||||||
|
handler.ResponseStatus = HttpStatusCode.Unauthorized;
|
||||||
|
handler.ResponseBody = "Unauthorized";
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<OnlineInboxException>(
|
||||||
|
() => client.PutListsAsync([]));
|
||||||
|
|
||||||
|
Assert.Equal(401, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ServerError_Throws_OnlineInboxException_WithStatusCode()
|
||||||
|
{
|
||||||
|
var (client, handler) = Build();
|
||||||
|
handler.ResponseStatus = HttpStatusCode.InternalServerError;
|
||||||
|
handler.ResponseBody = "error";
|
||||||
|
|
||||||
|
var ex = await Assert.ThrowsAsync<OnlineInboxException>(
|
||||||
|
() => client.GetUnimportedTasksAsync());
|
||||||
|
|
||||||
|
Assert.Equal(500, ex.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- No token ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task NoToken_SendsRequestWithoutAuthHeader()
|
||||||
|
{
|
||||||
|
var (client, handler) = Build(token: null);
|
||||||
|
await client.PutListsAsync([]);
|
||||||
|
|
||||||
|
Assert.Null(handler.Requests[0].Headers.Authorization);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- URL validation ----
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateBaseUrl_AcceptsHttps()
|
||||||
|
{
|
||||||
|
OnlineInboxApiClient.ValidateBaseUrl("https://example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateBaseUrl_AcceptsLoopback()
|
||||||
|
{
|
||||||
|
OnlineInboxApiClient.ValidateBaseUrl("http://127.0.0.1:5000");
|
||||||
|
OnlineInboxApiClient.ValidateBaseUrl("http://localhost:5000");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateBaseUrl_Rejects_HttpNonLoopback()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidOperationException>(
|
||||||
|
() => OnlineInboxApiClient.ValidateBaseUrl("http://example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ValidateBaseUrl_Rejects_Empty()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidOperationException>(
|
||||||
|
() => OnlineInboxApiClient.ValidateBaseUrl(""));
|
||||||
|
}
|
||||||
|
}
|
||||||
78
tests/ClaudeDo.Worker.Tests/Online/OnlineInboxConfigTests.cs
Normal file
78
tests/ClaudeDo.Worker.Tests/Online/OnlineInboxConfigTests.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
using ClaudeDo.Worker.Config;
|
||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Online;
|
||||||
|
|
||||||
|
public sealed class OnlineInboxConfigTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _configPath = Path.Combine(Path.GetTempPath(), $"worker_cfg_{Guid.NewGuid():N}.json");
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_configPath); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingSection_Returns_DisabledDefaults()
|
||||||
|
{
|
||||||
|
File.WriteAllText(_configPath, "{}");
|
||||||
|
var cfg = WorkerConfig.Load(_configPath);
|
||||||
|
|
||||||
|
Assert.False(cfg.OnlineInbox.Enabled);
|
||||||
|
Assert.Equal("", cfg.OnlineInbox.ApiBaseUrl);
|
||||||
|
Assert.Equal(60, cfg.OnlineInbox.PollIntervalSeconds);
|
||||||
|
Assert.Equal("", cfg.OnlineInbox.Zitadel.Authority);
|
||||||
|
Assert.Equal("", cfg.OnlineInbox.Zitadel.ClientId);
|
||||||
|
Assert.Equal("openid offline_access", cfg.OnlineInbox.Zitadel.Scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void MissingFile_Returns_DisabledDefaults()
|
||||||
|
{
|
||||||
|
var cfg = WorkerConfig.Load(Path.Combine(Path.GetTempPath(), $"nonexistent_{Guid.NewGuid():N}.json"));
|
||||||
|
|
||||||
|
Assert.False(cfg.OnlineInbox.Enabled);
|
||||||
|
Assert.Equal(60, cfg.OnlineInbox.PollIntervalSeconds);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PopulatedSection_RoundTrips()
|
||||||
|
{
|
||||||
|
var json = """
|
||||||
|
{
|
||||||
|
"online_inbox": {
|
||||||
|
"enabled": true,
|
||||||
|
"api_base_url": "https://inbox.claudedo.kuns.dev",
|
||||||
|
"poll_interval_seconds": 120,
|
||||||
|
"zitadel": {
|
||||||
|
"authority": "https://auth.example.com",
|
||||||
|
"client_id": "abc123",
|
||||||
|
"scopes": "openid offline_access profile"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
File.WriteAllText(_configPath, json);
|
||||||
|
var cfg = WorkerConfig.Load(_configPath);
|
||||||
|
|
||||||
|
Assert.True(cfg.OnlineInbox.Enabled);
|
||||||
|
Assert.Equal("https://inbox.claudedo.kuns.dev", cfg.OnlineInbox.ApiBaseUrl);
|
||||||
|
Assert.Equal(120, cfg.OnlineInbox.PollIntervalSeconds);
|
||||||
|
Assert.Equal("https://auth.example.com", cfg.OnlineInbox.Zitadel.Authority);
|
||||||
|
Assert.Equal("abc123", cfg.OnlineInbox.Zitadel.ClientId);
|
||||||
|
Assert.Equal("openid offline_access profile", cfg.OnlineInbox.Zitadel.Scopes);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void PartialSection_UsesDefaultsForMissingFields()
|
||||||
|
{
|
||||||
|
var json = """{"online_inbox": {"enabled": true}}""";
|
||||||
|
File.WriteAllText(_configPath, json);
|
||||||
|
var cfg = WorkerConfig.Load(_configPath);
|
||||||
|
|
||||||
|
Assert.True(cfg.OnlineInbox.Enabled);
|
||||||
|
Assert.Equal("", cfg.OnlineInbox.ApiBaseUrl);
|
||||||
|
Assert.Equal(60, cfg.OnlineInbox.PollIntervalSeconds);
|
||||||
|
}
|
||||||
|
}
|
||||||
242
tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs
Normal file
242
tests/ClaudeDo.Worker.Tests/Online/OnlineSyncServiceTests.cs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
53
tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs
Normal file
53
tests/ClaudeDo.Worker.Tests/Online/OnlineTokenStoreTests.cs
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Online;
|
||||||
|
|
||||||
|
public sealed class OnlineTokenStoreTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly string _tokenPath = Path.Combine(Path.GetTempPath(), $"online_token_{Guid.NewGuid():N}.bin");
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
try { File.Delete(_tokenPath); } catch { }
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Save_Read_RoundTrips()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return; // DPAPI is Windows-only
|
||||||
|
|
||||||
|
var store = new OnlineTokenStore(_tokenPath);
|
||||||
|
store.Save("my-refresh-token");
|
||||||
|
var result = store.Read();
|
||||||
|
Assert.Equal("my-refresh-token", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clear_Removes_Token()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return;
|
||||||
|
|
||||||
|
var store = new OnlineTokenStore(_tokenPath);
|
||||||
|
store.Save("token");
|
||||||
|
store.Clear();
|
||||||
|
Assert.Null(store.Read());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Read_WhenFileAbsent_Returns_Null()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return;
|
||||||
|
|
||||||
|
var store = new OnlineTokenStore(_tokenPath);
|
||||||
|
Assert.Null(store.Read());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Clear_WhenFileAbsent_DoesNotThrow()
|
||||||
|
{
|
||||||
|
if (!OperatingSystem.IsWindows()) return;
|
||||||
|
|
||||||
|
var store = new OnlineTokenStore(_tokenPath);
|
||||||
|
store.Clear(); // no exception expected
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
using ClaudeDo.Worker.Online;
|
||||||
|
|
||||||
|
namespace ClaudeDo.Worker.Tests.Online;
|
||||||
|
|
||||||
|
public sealed class StaticTokenAuthProviderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task WithToken_Returns_Token()
|
||||||
|
{
|
||||||
|
var provider = new StaticTokenAuthProvider("my-token");
|
||||||
|
var result = await provider.GetAccessTokenAsync();
|
||||||
|
Assert.Equal("my-token", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task WithNull_Returns_Null()
|
||||||
|
{
|
||||||
|
var provider = new StaticTokenAuthProvider(null);
|
||||||
|
var result = await provider.GetAccessTokenAsync();
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Default_Returns_Null()
|
||||||
|
{
|
||||||
|
var provider = new StaticTokenAuthProvider();
|
||||||
|
var result = await provider.GetAccessTokenAsync();
|
||||||
|
Assert.Null(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user