Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 21 additions & 3 deletions src/TradingSystem.Functions/IncomeSleeveFunction.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,13 +12,27 @@ public class IncomeSleeveFunction
{
private readonly ILogger<IncomeSleeveFunction> _logger;
private readonly TradingSystemConfig _config;
private readonly Func<DateTime> _utcNow;

public IncomeSleeveFunction(
ILogger<IncomeSleeveFunction> logger,
IOptions<TradingSystemConfig> config)
: this(logger, config, () => DateTime.UtcNow)
{
}

/// <summary>
/// Testable-clock seam (S6-007, Default D1): tests pin the day-of-month guard;
/// S6-001 reuses this seam for the first-trading-weekday gate.
/// </summary>
internal IncomeSleeveFunction(
ILogger<IncomeSleeveFunction> logger,
IOptions<TradingSystemConfig> config,
Func<DateTime> utcNow)
{
_logger = logger;
_config = config.Value;
_utcNow = utcNow;
}

/// <summary>
Expand All @@ -30,7 +44,7 @@ public async Task RunMonthlyReinvest(
CancellationToken cancellationToken)
{
// Only run on the first weekday of the month
if (DateTime.UtcNow.Day > 7) return;
if (_utcNow().Day > 7) return;

var runId = Guid.NewGuid().ToString("N")[..8];
_logger.LogInformation("Starting monthly income reinvest. RunId: {RunId}", runId);
Expand Down Expand Up @@ -64,7 +78,7 @@ public async Task RunQuarterlyAudit(
[TimerTrigger("0 0 14 1-7 1,4,7,10 1-5")] TimerInfo timer,
CancellationToken cancellationToken)
{
if (DateTime.UtcNow.Day > 7) return;
if (_utcNow().Day > 7) return;

var runId = Guid.NewGuid().ToString("N")[..8];
_logger.LogInformation("Starting quarterly quality audit. RunId: {RunId}", runId);
Expand All @@ -79,7 +93,11 @@ public async Task RunQuarterlyAudit(
// 5. Generate reduction signals if needed
// 6. Send report to owner

_logger.LogInformation("Quarterly quality audit complete. RunId: {RunId}", runId);
// S6-007 stub honesty: during the paper-validation run, logs are evidence.
// Do NOT log "complete" for work that never happened.
_logger.LogWarning(
"IncomeSleeve_QuarterlyAudit is not implemented — skipped (deferred per backlog, S7+). RunId: {RunId}",
runId);
}
catch (Exception ex)
{
Expand Down
130 changes: 130 additions & 0 deletions tests/TradingSystem.Tests/Functions/IncomeSleeveFunctionTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
using System.Text.RegularExpressions;
using Microsoft.Azure.Functions.Worker;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Moq;
using TradingSystem.Core.Configuration;
using TradingSystem.Functions;
using Xunit;
using MsOptions = Microsoft.Extensions.Options.Options;

namespace TradingSystem.Tests.Functions;

/// <summary>
/// S6-007: quarterly-audit stub honesty. During the live paper-validation run, logs are
/// evidence — a TODO stub that logs "Quarterly quality audit complete" while doing nothing
/// would falsify the run record in the Jul 1–7 audit window. The stub must instead log an
/// explicit Warning that it is not implemented and was skipped. Also covers the internal
/// Func&lt;DateTime&gt; clock seam (Default D1) that S6-001 rebases on: both timer methods
/// read the injected clock instead of DateTime.UtcNow, so the day-of-month guard is testable.
/// No Discord send, no cron change, no behavior added.
/// </summary>
public class IncomeSleeveFunctionTests
{
// ---------- harness ----------

private static (IncomeSleeveFunction Function, Mock<ILogger<IncomeSleeveFunction>> Logger) Build(DateTime pinnedUtcNow)
{
var logger = new Mock<ILogger<IncomeSleeveFunction>>();
var config = MsOptions.Create(new TradingSystemConfig());
var function = new IncomeSleeveFunction(logger.Object, config, () => pinnedUtcNow);
return (function, logger);
}

private static List<(LogLevel Level, string Message)> CapturedLogs(Mock<ILogger<IncomeSleeveFunction>> logger)
{
var entries = new List<(LogLevel, string)>();
foreach (var inv in logger.Invocations)
{
if (inv.Method.Name != nameof(ILogger.Log))
continue;
var level = (LogLevel)inv.Arguments[0];
var message = inv.Arguments[2]?.ToString() ?? string.Empty;
entries.Add((level, message));
}
return entries;
}

// ---------- quarterly audit: honesty (S6-007 test plan 1) ----------

[Fact]
public async Task RunQuarterlyAudit_WithinFirstWeek_LogsExactlyOneNotImplementedWarning()
{
// 2026-07-01 is a Wednesday with Day = 1 (≤ 7): inside the audit window.
var (function, logger) = Build(new DateTime(2026, 7, 1, 14, 0, 0, DateTimeKind.Utc));

await function.RunQuarterlyAudit(new TimerInfo(), CancellationToken.None);

var warnings = CapturedLogs(logger)
.Where(e => e.Level == LogLevel.Warning)
.Select(e => e.Message)
.ToList();

var honest = warnings.Where(m => m.Contains("not implemented — skipped")).ToList();
Assert.Single(honest);

// The warning carries the same runId as the start-of-run Information line.
var startMessage = CapturedLogs(logger)
.Where(e => e.Level == LogLevel.Information)
.Select(e => e.Message)
.Single(m => m.Contains("Starting quarterly quality audit"));
var runId = Regex.Match(startMessage, @"RunId: (\w{8})").Groups[1].Value;
Assert.False(string.IsNullOrEmpty(runId));
Assert.Contains($"RunId: {runId}", honest[0]);
}

[Fact]
public async Task RunQuarterlyAudit_WithinFirstWeek_NoLongerLogsComplete()
{
var (function, logger) = Build(new DateTime(2026, 7, 1, 14, 0, 0, DateTimeKind.Utc));

await function.RunQuarterlyAudit(new TimerInfo(), CancellationToken.None);

var informationMessages = CapturedLogs(logger)
.Where(e => e.Level == LogLevel.Information)
.Select(e => e.Message);
Assert.DoesNotContain(informationMessages,
m => m.Contains("complete", StringComparison.OrdinalIgnoreCase));
}

// ---------- quarterly audit: guard intact via clock seam (S6-007 test plan 2) ----------

[Fact]
public async Task RunQuarterlyAudit_AfterFirstWeek_ReturnsWithoutLoggingWarning()
{
// Day 8 (> 7): the existing guard must return before any logging.
var (function, logger) = Build(new DateTime(2026, 7, 8, 14, 0, 0, DateTimeKind.Utc));

await function.RunQuarterlyAudit(new TimerInfo(), CancellationToken.None);

Assert.Empty(CapturedLogs(logger));
}

// ---------- monthly reinvest: untouched in this item (S6-007 test plan 4) ----------

[Fact]
public async Task RunMonthlyReinvest_WithinFirstWeek_BehaviorUnchanged_StillLogsComplete()
{
// S6-001 owns the reinvest wiring; this item must not change its behavior.
var (function, logger) = Build(new DateTime(2026, 7, 1, 13, 30, 0, DateTimeKind.Utc));

await function.RunMonthlyReinvest(new TimerInfo(), CancellationToken.None);

var informationMessages = CapturedLogs(logger)
.Where(e => e.Level == LogLevel.Information)
.Select(e => e.Message)
.ToList();
Assert.Contains(informationMessages, m => m.Contains("Monthly income reinvest complete"));
}

[Fact]
public async Task RunMonthlyReinvest_AfterFirstWeek_GuardUsesInjectedClock()
{
// Proves the monthly method reads the seam (S6-001 rebases on this), guard intact.
var (function, logger) = Build(new DateTime(2026, 7, 8, 13, 30, 0, DateTimeKind.Utc));

await function.RunMonthlyReinvest(new TimerInfo(), CancellationToken.None);

Assert.Empty(CapturedLogs(logger));
}
}
Loading