feat(usage): parse the TokenTracker sessions export defensively
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
using ClaudeDo.Worker.Usage.TokenTracker;
|
||||
|
||||
namespace ClaudeDo.Worker.Tests.Usage.TokenTracker;
|
||||
|
||||
public sealed class TokenTrackerExportParserTests
|
||||
{
|
||||
private static readonly DateTime FetchedAt = new(2026, 8, 24, 9, 0, 0, DateTimeKind.Utc);
|
||||
|
||||
[Fact]
|
||||
public void Parse_ReadsAllSessions()
|
||||
{
|
||||
var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.SessionsJson, FetchedAt);
|
||||
|
||||
Assert.NotNull(export);
|
||||
Assert.Equal(4, export!.Sessions.Count);
|
||||
Assert.Equal(11, export.FormatVersion);
|
||||
Assert.Equal(FetchedAt, export.FetchedAtUtc);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MapsTokenFieldsAndCost()
|
||||
{
|
||||
var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.SessionsJson, FetchedAt)!;
|
||||
var first = export.Sessions.Single(s => s.SessionHash == "b24babcbf2b615730459773f");
|
||||
|
||||
Assert.Equal("claude-opus-5", first.Model);
|
||||
Assert.Equal(100, first.InputTokens);
|
||||
Assert.Equal(400, first.OutputTokens);
|
||||
Assert.Equal(900, first.CacheReadTokens);
|
||||
Assert.Equal(50, first.CacheCreationTokens);
|
||||
Assert.Equal(2.5, first.CostUsd);
|
||||
Assert.Equal(4, first.Turns);
|
||||
Assert.Equal(1, first.RetryTurns);
|
||||
Assert.True(first.Productive);
|
||||
Assert.True(first.OneShot);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_UnsupportedVersion_StillParsesButReportsIt()
|
||||
{
|
||||
var export = TokenTrackerExportParser.Parse(TokenTrackerFixtures.UnsupportedVersionJson, FetchedAt);
|
||||
|
||||
Assert.NotNull(export);
|
||||
Assert.Equal(99, export!.FormatVersion);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_MissingFields_DefaultToZero()
|
||||
{
|
||||
const string json = """
|
||||
{ "sessions": [ { "version": 11, "session_hash": "abc", "source": "claude",
|
||||
"model": "claude-opus-5", "started_at": "2026-08-20T10:00:00.000Z" } ] }
|
||||
""";
|
||||
|
||||
var session = TokenTrackerExportParser.Parse(json, FetchedAt)!.Sessions.Single();
|
||||
|
||||
Assert.Equal(0, session.InputTokens);
|
||||
Assert.Equal(0, session.CostUsd);
|
||||
Assert.Equal(0, session.RetryTurns);
|
||||
Assert.False(session.Productive);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_SkipsRowsWithoutHashOrTimestamp()
|
||||
{
|
||||
const string json = """
|
||||
{ "sessions": [ { "version": 11, "source": "claude", "model": "m" },
|
||||
{ "version": 11, "session_hash": "abc", "source": "claude", "model": "m" },
|
||||
{ "version": 11, "session_hash": "def", "source": "claude", "model": "m",
|
||||
"started_at": "2026-08-20T10:00:00.000Z" } ] }
|
||||
""";
|
||||
|
||||
var export = TokenTrackerExportParser.Parse(json, FetchedAt);
|
||||
|
||||
Assert.Single(export!.Sessions);
|
||||
Assert.Equal("def", export.Sessions[0].SessionHash);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Parse_SkipsNonClaudeSources()
|
||||
{
|
||||
const string json = """
|
||||
{ "sessions": [ { "version": 11, "session_hash": "abc", "source": "codex", "model": "gpt",
|
||||
"started_at": "2026-08-20T10:00:00.000Z" } ] }
|
||||
""";
|
||||
|
||||
Assert.Empty(TokenTrackerExportParser.Parse(json, FetchedAt)!.Sessions);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("")]
|
||||
[InlineData(" ")]
|
||||
[InlineData("not json at all")]
|
||||
[InlineData("[1,2,3]")]
|
||||
[InlineData("{\"available\": false}")]
|
||||
public void Parse_Garbage_ReturnsNull(string json)
|
||||
{
|
||||
Assert.Null(TokenTrackerExportParser.Parse(json, FetchedAt));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user