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:
mika kuns
2026-06-10 09:55:20 +02:00
parent 8cbe1adb32
commit 1ac9ced0bd
22 changed files with 1196 additions and 0 deletions

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

View 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(""));
}
}

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

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

View 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
}
}

View File

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