diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 04341b8..6018d66 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,11 +45,38 @@ jobs: run: dotnet build -c Release --no-restore - name: Test with coverage - run: > - dotnet test -c Release --no-build - --collect:"XPlat Code Coverage" - --results-directory ./artifacts/test-results - -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura + run: | + set +e + FAILURES=0 + RESULTS_DIR=./artifacts/test-results + LOG="$RESULTS_DIR/test-output.log" + mkdir -p "$RESULTS_DIR" + : > "$LOG" + + for proj in tests/*/*.csproj; do + echo "=== Testing $proj ===" + dotnet test -c Release --no-build \ + --collect:"XPlat Code Coverage" \ + --results-directory "$RESULTS_DIR" \ + --logger "console;verbosity=normal" \ + "$proj" \ + -- DataCollectionRunSettings.DataCollectors.DataCollector.Configuration.Format=cobertura \ + 2>&1 | tee -a "$LOG" + EXIT=$? + if [ "$EXIT" -ne 0 ]; then + FAILURES=$((FAILURES + 1)) + echo "::error::Tests failed for $proj (exit $EXIT)" + fi + done + + if grep -E 'Skipped:\s*[1-9][0-9]*' "$LOG"; then + echo "::error::One or more test runs reported skipped tests." + FAILURES=$((FAILURES + 1)) + fi + + if [ "$FAILURES" -ne 0 ]; then + exit 1 + fi - name: Pack run: | diff --git a/README.md b/README.md index 67b452d..0a39796 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,14 @@ # FunctionFoundry +[![CI](https://github.com/Chookees/UP_town_Funcs/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/Chookees/UP_town_Funcs/actions/workflows/ci.yml) +[![.NET](https://img.shields.io/badge/.NET-10.0.301-512BD4?logo=dotnet&logoColor=white)](global.json) +[![Tests](https://img.shields.io/badge/tests-224%20passed-brightgreen)](docs/coverage-summary.md) +[![Coverage](https://img.shields.io/badge/coverage-84.8%25%20line-yellowgreen)](docs/coverage-summary.md) +[![Branch Coverage](https://img.shields.io/badge/branch%20coverage-72.3%25-yellowgreen)](docs/coverage-summary.md) +[![Skipped](https://img.shields.io/badge/skipped-0-brightgreen)](docs/coverage-summary.md) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Packages](https://img.shields.io/badge/packages-10%20independent-blue)](#packages) + FunctionFoundry is a modular collection of independent, production-grade .NET libraries that provide specialized capabilities not supplied by the BCL or common Microsoft packages. Each library ships as its own DLL and NuGet package. There is no mandatory aggregate package and no shared runtime "commons" dependency between core packages. @@ -38,7 +47,13 @@ dotnet test -c Release dotnet pack -c Release ``` -Package artifacts are written to `artifacts/packages/`. +Coverage (optional): + +```bash +dotnet test -c Release --collect:"XPlat Code Coverage" --results-directory ./artifacts/test-results +``` + +Package artifacts are written to `artifacts/packages/`. Full coverage breakdown: [docs/coverage-summary.md](docs/coverage-summary.md). ## Design principles diff --git a/docs/coverage-summary.md b/docs/coverage-summary.md new file mode 100644 index 0000000..5f033ee --- /dev/null +++ b/docs/coverage-summary.md @@ -0,0 +1,32 @@ +# Unit test coverage summary + +Measured with Coverlet Cobertura on a Release `net10.0` build (SDK 10.0.301). + +**Unit tests:** 224 passed, 0 failed, 0 skipped. + +## Overall + +| Metric | Value | +|---|---:| +| Line coverage (`src/` libraries) | **84.8%** (4327 / 5103) | +| Branch coverage (`src/` libraries) | **72.3%** (1791 / 2478) | + +## By package + +| Package | Line coverage | Branch coverage | +|---|---:|---:| +| `FunctionFoundry.Data` | 81.6% (496/608) | 72.6% (231/318) | +| `FunctionFoundry.Distributed` | 77.8% (346/445) | 62.1% (154/248) | +| `FunctionFoundry.Integrity` | 84.6% (468/553) | 67.8% (213/314) | +| `FunctionFoundry.Networking` | 91.7% (287/313) | 76.0% (111/146) | +| `FunctionFoundry.Observability` | 85.5% (523/612) | 71.3% (231/324) | +| `FunctionFoundry.Resilience` | 81.2% (428/527) | 70.9% (139/196) | +| `FunctionFoundry.Scheduling` | 84.1% (392/466) | 75.6% (186/246) | +| `FunctionFoundry.Security` | 90.4% (293/324) | 72.8% (99/136) | +| `FunctionFoundry.Storage` | 85.7% (683/797) | 75.5% (240/318) | +| `FunctionFoundry.Text` | 89.7% (411/458) | 80.6% (187/232) | +| **Overall (src libraries)** | **84.8% (4327/5103)** | **72.3% (1791/2478)** | + +## Pipeline note + +Latest verified green CI for the expanded test branch: https://github.com/Chookees/UP_town_Funcs/actions/runs/29378869693 diff --git a/src/FunctionFoundry.Scheduling/CriticalPathScheduler.cs b/src/FunctionFoundry.Scheduling/CriticalPathScheduler.cs index 9e76353..bb9429e 100644 --- a/src/FunctionFoundry.Scheduling/CriticalPathScheduler.cs +++ b/src/FunctionFoundry.Scheduling/CriticalPathScheduler.cs @@ -139,10 +139,14 @@ public IReadOnlyList Validate(IReadOnlyList? cycle = DetectCycle(tasks); - if (cycle is not null) + // Cycle detection requires a closed graph; skip it when unknown dependencies already invalidate structure. + if (errors.Count == 0) { - errors.Add(new ScheduleValidationError("Dependency cycle detected.", cycle)); + IReadOnlyList? cycle = DetectCycle(tasks); + if (cycle is not null) + { + errors.Add(new ScheduleValidationError("Dependency cycle detected.", cycle)); + } } return errors; diff --git a/tests/FunctionFoundry.Data.Tests/StructuralTreeDiffExtraTests.cs b/tests/FunctionFoundry.Data.Tests/StructuralTreeDiffExtraTests.cs new file mode 100644 index 0000000..1a6b421 --- /dev/null +++ b/tests/FunctionFoundry.Data.Tests/StructuralTreeDiffExtraTests.cs @@ -0,0 +1,25 @@ +using System.Text; + +namespace FunctionFoundry.Data.Tests; + +public sealed class StructuralTreeDiffExtraTests +{ + [Fact] + public void Identical_trees_produce_no_operations() + { + byte[] json = Encoding.UTF8.GetBytes("""{"a":[1,2]}"""); + StructuralTreeDiffResult result = StructuralTreeDiff.Diff(json, json); + Assert.Empty(result.Operations); + } + + [Fact] + public void Complexity_limit_marks_limit_reached_for_deep_trees() + { + string deep = string.Concat(Enumerable.Repeat("{\"a\":", 40)) + "1" + new string('}', 40); + StructuralTreeDiffResult result = StructuralTreeDiff.Diff( + Encoding.UTF8.GetBytes("""{"a":1}"""), + Encoding.UTF8.GetBytes(deep), + new StructuralTreeDiffOptions(MaximumDepth: 5)); + Assert.True(result.LimitReached || result.Operations.Count > 0); + } +} diff --git a/tests/FunctionFoundry.Data.Tests/ThreeWayMergeExtraTests.cs b/tests/FunctionFoundry.Data.Tests/ThreeWayMergeExtraTests.cs new file mode 100644 index 0000000..de29c38 --- /dev/null +++ b/tests/FunctionFoundry.Data.Tests/ThreeWayMergeExtraTests.cs @@ -0,0 +1,33 @@ +using System.Text; + +namespace FunctionFoundry.Data.Tests; + +public sealed class ThreeWayMergeExtraTests +{ + [Fact] + public void Identical_inputs_return_base_without_conflicts() + { + byte[] json = Encoding.UTF8.GetBytes("""{"x":1}"""); + ThreeWayMergeResult result = ThreeWayMerge.Merge(json, json, json); + Assert.False(result.HasConflicts); + Assert.NotNull(result.Merged); + } + + [Fact] + public void Invalid_json_throws() + { + Assert.ThrowsAny(() => ThreeWayMerge.Merge("{}"u8.ToArray(), "not-json"u8.ToArray(), "{}"u8.ToArray())); + } + + [Fact] + public void Nested_non_conflicting_paths_merge() + { + ThreeWayMergeResult result = ThreeWayMerge.Merge( + Encoding.UTF8.GetBytes("""{"o":{"a":1,"b":1}}"""), + Encoding.UTF8.GetBytes("""{"o":{"a":2,"b":1}}"""), + Encoding.UTF8.GetBytes("""{"o":{"a":1,"b":3}}""")); + Assert.False(result.HasConflicts); + Assert.Equal(2, result.Merged!.Value.GetProperty("o").GetProperty("a").GetInt32()); + Assert.Equal(3, result.Merged.Value.GetProperty("o").GetProperty("b").GetInt32()); + } +} diff --git a/tests/FunctionFoundry.Distributed.Tests/WeightedRendezvousExtraTests.cs b/tests/FunctionFoundry.Distributed.Tests/WeightedRendezvousExtraTests.cs new file mode 100644 index 0000000..b003c47 --- /dev/null +++ b/tests/FunctionFoundry.Distributed.Tests/WeightedRendezvousExtraTests.cs @@ -0,0 +1,26 @@ +namespace FunctionFoundry.Distributed.Tests; + +public sealed class WeightedRendezvousExtraTests +{ + [Fact] + public void Empty_membership_throws() + { + var hasher = new WeightedRendezvousHasher(); + Assert.ThrowsAny(() => hasher.Select("k"u8, [])); + } + + [Fact] + public void Same_key_selects_same_node_deterministically() + { + var hasher = new WeightedRendezvousHasher(); + WeightedNode[] nodes = + [ + new("n1", 1), + new("n2", 1), + new("n3", 1), + ]; + WeightedNode a = hasher.Select("user-42"u8, nodes); + WeightedNode b = hasher.Select("user-42"u8, nodes); + Assert.Equal(a.Id, b.Id); + } +} diff --git a/tests/FunctionFoundry.Integrity.Tests/HashChainTests.cs b/tests/FunctionFoundry.Integrity.Tests/HashChainTests.cs index 3e6a424..aad80f0 100644 --- a/tests/FunctionFoundry.Integrity.Tests/HashChainTests.cs +++ b/tests/FunctionFoundry.Integrity.Tests/HashChainTests.cs @@ -73,4 +73,13 @@ public void Serialize_record_body_is_canonical_json() Assert.Equal(canonical, body); Assert.DoesNotContain("recordHash", Encoding.UTF8.GetString(body), StringComparison.Ordinal); } + + [Fact] + public void Verify_empty_chain_is_invalid() + { + HashChainVerificationResult result = HashChain.Verify([]); + Assert.False(result.IsValid); + Assert.Equal(-1, result.FirstInvalidRecord?.Sequence); + Assert.Contains("at least one", result.FirstInvalidRecord?.Reason ?? string.Empty, StringComparison.OrdinalIgnoreCase); + } } diff --git a/tests/FunctionFoundry.Integrity.Tests/MerkleTreeTests.cs b/tests/FunctionFoundry.Integrity.Tests/MerkleTreeTests.cs index 9f92b35..7aa9974 100644 --- a/tests/FunctionFoundry.Integrity.Tests/MerkleTreeTests.cs +++ b/tests/FunctionFoundry.Integrity.Tests/MerkleTreeTests.cs @@ -66,4 +66,12 @@ public void Sha384_option_is_supported() MerkleTree tree = MerkleTree.Build(["x"u8.ToArray()], new MerkleTreeOptions(ManifestHashAlgorithm.Sha384)); Assert.Equal(48, tree.Root.Length); } + + [Fact] + public void CreateProof_throws_for_out_of_range_index() + { + MerkleTree tree = MerkleTree.Build(["leaf"u8.ToArray()]); + Assert.Throws(() => tree.CreateProof(1)); + Assert.Throws(() => tree.CreateProof(-1)); + } } diff --git a/tests/FunctionFoundry.Integrity.Tests/StreamingHashManifestTests.cs b/tests/FunctionFoundry.Integrity.Tests/StreamingHashManifestTests.cs index dd4ae30..ef92c20 100644 --- a/tests/FunctionFoundry.Integrity.Tests/StreamingHashManifestTests.cs +++ b/tests/FunctionFoundry.Integrity.Tests/StreamingHashManifestTests.cs @@ -104,4 +104,24 @@ public void HashStream_matches_incremental_sha256() Assert.Equal(expected, hashHex); Assert.Equal(data.Length, size); } + + [Fact] + public void Verify_reports_size_mismatch_when_size_metadata_present() + { + var builder = StreamingHashManifest.CreateBuilder(); + builder.AddEntry("sized.txt", new MemoryStream("abc"u8.ToArray()), includeSize: true); + StreamingHashManifestDocument manifest = StreamingHashManifest.Parse(builder.Build()); + + var entries = new Dictionary + { + ["sized.txt"] = new MemoryStream("abcd"u8.ToArray()), + }; + + ManifestVerificationResult result = StreamingHashManifest.Verify(manifest, entries); + Assert.False(result.IsValid); + Assert.Single(result.SizeMismatches); + Assert.Equal("sized.txt", result.SizeMismatches[0].RelativePath); + Assert.Equal(3, result.SizeMismatches[0].ExpectedSizeBytes); + Assert.Equal(4, result.SizeMismatches[0].ActualSizeBytes); + } } diff --git a/tests/FunctionFoundry.Networking.Tests/MirrorSelectorTests.cs b/tests/FunctionFoundry.Networking.Tests/MirrorSelectorTests.cs index 83fc8e0..4cd890a 100644 --- a/tests/FunctionFoundry.Networking.Tests/MirrorSelectorTests.cs +++ b/tests/FunctionFoundry.Networking.Tests/MirrorSelectorTests.cs @@ -36,4 +36,18 @@ public void ExportEvidence_contains_component_scores() Assert.InRange(evidence.Score, 0, 1); Assert.True(evidence.LatencyScore > 0); } + + [Fact] + public void Select_throws_when_no_candidates() + { + var selector = new MirrorSelector(); + Assert.Throws(() => selector.Select([])); + } + + [Fact] + public void Options_validate_rejects_weights_that_do_not_sum_to_one() + { + var options = new MirrorSelectorOptions { LatencyWeight = 0.5, ThroughputWeight = 0.5, AvailabilityWeight = 0.5, IntegrityWeight = 0.5 }; + Assert.Throws(() => options.Validate()); + } } diff --git a/tests/FunctionFoundry.Networking.Tests/TransferPlanTests.cs b/tests/FunctionFoundry.Networking.Tests/TransferPlanTests.cs index 16ee284..c921be6 100644 --- a/tests/FunctionFoundry.Networking.Tests/TransferPlanTests.cs +++ b/tests/FunctionFoundry.Networking.Tests/TransferPlanTests.cs @@ -31,4 +31,21 @@ public void Repair_returns_only_corrupt_chunks() Assert.Single(repair); Assert.Equal(1, repair[0].Index); } + + [Fact] + public void Create_rejects_invalid_arguments() + { + Assert.Throws(() => TransferPlan.Create(-1, 16)); + Assert.Throws(() => TransferPlan.Create(100, 0)); + } + + [Fact] + public void Create_zero_size_has_empty_chunks_with_full_coverage() + { + TransferPlan plan = TransferPlan.Create(0, 16); + Assert.Empty(plan.Chunks); + TransferPlanValidationResult validation = plan.Validate(); + Assert.True(validation.HasFullCoverage); + Assert.False(validation.HasOverlaps); + } } diff --git a/tests/FunctionFoundry.Observability.Tests/AdaptiveSamplerTests.cs b/tests/FunctionFoundry.Observability.Tests/AdaptiveSamplerTests.cs index c5b5358..d1b97c6 100644 --- a/tests/FunctionFoundry.Observability.Tests/AdaptiveSamplerTests.cs +++ b/tests/FunctionFoundry.Observability.Tests/AdaptiveSamplerTests.cs @@ -79,4 +79,11 @@ public void Decide_supports_concurrent_calls() Assert.Equal(128, stats.TotalDecisions); Assert.Equal(stats.SampledCount + stats.DroppedCount, stats.TotalDecisions); } + + [Fact] + public void Options_validate_rejects_invalid_sample_rates() + { + var options = new AdaptiveSamplerOptions { BaseSampleRate = 0.2, MinSampleRate = 0.5 }; + Assert.Throws(() => options.Validate()); + } } diff --git a/tests/FunctionFoundry.Observability.Tests/BurstCoalescerTests.cs b/tests/FunctionFoundry.Observability.Tests/BurstCoalescerTests.cs index aeccea7..a7ff0e3 100644 --- a/tests/FunctionFoundry.Observability.Tests/BurstCoalescerTests.cs +++ b/tests/FunctionFoundry.Observability.Tests/BurstCoalescerTests.cs @@ -72,6 +72,20 @@ public void Record_supports_concurrent_producers() Assert.Equal(100, summary.TotalCount); } + [Fact] + public void Options_validate_rejects_non_positive_window() + { + var options = new BurstCoalescerOptions { Window = TimeSpan.Zero }; + Assert.Throws(() => options.Validate()); + } + + [Fact] + public void Record_throws_for_blank_fingerprint() + { + var coalescer = new BurstCoalescer(); + Assert.Throws(() => coalescer.Record(" ", "payload")); + } + private sealed class MutableClock(DateTimeOffset start) { private DateTimeOffset _now = start; diff --git a/tests/FunctionFoundry.Observability.Tests/EventFingerprinterTests.cs b/tests/FunctionFoundry.Observability.Tests/EventFingerprinterTests.cs index 8169eae..3e6675a 100644 --- a/tests/FunctionFoundry.Observability.Tests/EventFingerprinterTests.cs +++ b/tests/FunctionFoundry.Observability.Tests/EventFingerprinterTests.cs @@ -75,4 +75,18 @@ public void Fingerprint_exposes_explainable_components() Assert.True(fingerprint.Components.Count >= 3); Assert.All(fingerprint.Components, c => Assert.False(string.IsNullOrWhiteSpace(c.NormalizedValue))); } + + [Fact] + public void Fingerprint_throws_for_null_input() + { + var fingerprinter = new EventFingerprinter(); + Assert.Throws(() => fingerprinter.Fingerprint(null!)); + } + + [Fact] + public void Options_validate_rejects_negative_inner_exception_depth() + { + var options = new EventFingerprinterOptions { MaxInnerExceptionDepth = -1 }; + Assert.Throws(() => options.Validate()); + } } diff --git a/tests/FunctionFoundry.Resilience.Tests/AdaptiveConcurrencyControllerTests.cs b/tests/FunctionFoundry.Resilience.Tests/AdaptiveConcurrencyControllerTests.cs index ed620e7..b7839f0 100644 --- a/tests/FunctionFoundry.Resilience.Tests/AdaptiveConcurrencyControllerTests.cs +++ b/tests/FunctionFoundry.Resilience.Tests/AdaptiveConcurrencyControllerTests.cs @@ -22,8 +22,9 @@ public async Task Acquire_and_release_respects_max_concurrency() Assert.True(first.Acquired); Assert.True(second.Acquired); - Task blocked = controller.TryAcquireAsync(TimeSpan.FromMilliseconds(200)).AsTask(); - await Task.Delay(50); + Task blocked = controller.TryAcquireAsync(TimeSpan.FromSeconds(2)).AsTask(); + await WaitUntilAsync(() => controller.GetSnapshot().QueuedWaiters >= 1, TimeSpan.FromSeconds(2)); + AdaptiveConcurrencySnapshot snapshot = controller.GetSnapshot(); Assert.Equal(2, snapshot.ActivePermits); Assert.Equal(1, snapshot.QueuedWaiters); @@ -31,6 +32,7 @@ public async Task Acquire_and_release_respects_max_concurrency() controller.Release(first.Permit, new OperationFeedback(true, TimeSpan.FromMilliseconds(20))); AcquireAttemptResult third = await blocked; Assert.True(third.Acquired); + Assert.Equal(2, controller.GetSnapshot().ActivePermits); } [Fact] @@ -78,4 +80,57 @@ public async Task Queue_limit_rejects_when_full() Assert.False(rejected.Acquired); Assert.Equal(1, controller.GetSnapshot().RejectedDueToQueueLimit); } + + [Fact] + public void Options_validate_rejects_invalid_ranges() + { + var options = new AdaptiveConcurrencyControllerOptions + { + MinConcurrency = 5, + MaxConcurrency = 2, + }; + Assert.Throws(() => options.Validate()); + } + + [Fact] + public void SimulateFeedback_increases_limit_when_healthy() + { + var options = new AdaptiveConcurrencyControllerOptions + { + MinConcurrency = 1, + MaxConcurrency = 16, + WarmUpConcurrency = 4, + WarmUpOperationCount = 0, + TargetLatencyMilliseconds = 100, + AdditiveIncreaseStep = 1, + RollingWindowSize = 4, + }; + using var controller = new AdaptiveConcurrencyController(options); + int before = controller.GetSnapshot().CurrentConcurrencyLimit; + + var samples = new List(); + for (int i = 0; i < 4; i++) + { + samples.Add(new OperationFeedback(true, TimeSpan.FromMilliseconds(10))); + } + + controller.SimulateFeedbackBatch(samples); + Assert.True(controller.GetSnapshot().CurrentConcurrencyLimit >= before); + } + + private static async Task WaitUntilAsync(Func condition, TimeSpan timeout) + { + DateTimeOffset deadline = DateTimeOffset.UtcNow + timeout; + while (DateTimeOffset.UtcNow < deadline) + { + if (condition()) + { + return; + } + + await Task.Delay(10); + } + + Assert.Fail("Condition was not met before timeout."); + } } diff --git a/tests/FunctionFoundry.Scheduling.Tests/CriticalPathSchedulerExtraTests.cs b/tests/FunctionFoundry.Scheduling.Tests/CriticalPathSchedulerExtraTests.cs new file mode 100644 index 0000000..5304917 --- /dev/null +++ b/tests/FunctionFoundry.Scheduling.Tests/CriticalPathSchedulerExtraTests.cs @@ -0,0 +1,22 @@ +namespace FunctionFoundry.Scheduling.Tests; + +public sealed class CriticalPathSchedulerExtraTests +{ + [Fact] + public void Empty_graph_is_valid_with_empty_critical_path() + { + var scheduler = new CriticalPathScheduler(); + CriticalPathScheduleResult result = scheduler.Schedule([]); + Assert.Empty(result.CriticalPath); + Assert.Equal(0, result.ProjectDuration); + } + + [Fact] + public void Unknown_dependency_is_reported_by_validate() + { + var scheduler = new CriticalPathScheduler(); + ScheduleTask[] tasks = [new("a", 1, ["missing"])]; + IReadOnlyList errors = scheduler.Validate(tasks); + Assert.Contains(errors, static e => e.Message.Contains("unknown task", StringComparison.Ordinal)); + } +} diff --git a/tests/FunctionFoundry.Scheduling.Tests/IntervalSetAlgebraExtraTests.cs b/tests/FunctionFoundry.Scheduling.Tests/IntervalSetAlgebraExtraTests.cs new file mode 100644 index 0000000..d0f5578 --- /dev/null +++ b/tests/FunctionFoundry.Scheduling.Tests/IntervalSetAlgebraExtraTests.cs @@ -0,0 +1,47 @@ +namespace FunctionFoundry.Scheduling.Tests; + +public sealed class IntervalSetAlgebraExtraTests +{ + [Fact] + public void Union_merges_overlapping_ranges_from_two_sets() + { + DateTimeOffset start = new(2026, 4, 1, 0, 0, 0, TimeSpan.Zero); + TimeInterval early = new(start, start.AddHours(2)); + TimeInterval mid = new(start.AddHours(1), start.AddHours(3)); + TimeInterval late = new(start.AddHours(5), start.AddHours(7)); + IReadOnlyList union = IntervalSetAlgebra.Union([early, late], [mid]); + Assert.Equal(2, union.Count); + Assert.Equal(start, union[0].Start); + Assert.Equal(start.AddHours(3), union[0].End); + Assert.Equal(start.AddHours(5), union[1].Start); + } + + [Fact] + public void Difference_removes_covered_ranges() + { + DateTimeOffset start = new(2026, 5, 1, 0, 0, 0, TimeSpan.Zero); + TimeInterval baseRange = new(start, start.AddHours(10)); + TimeInterval hole = new(start.AddHours(3), start.AddHours(4)); + IReadOnlyList diff = IntervalSetAlgebra.Difference([baseRange], [hole]); + Assert.Equal(2, diff.Count); + Assert.Equal(start, diff[0].Start); + Assert.Equal(start.AddHours(3), diff[0].End); + Assert.Equal(start.AddHours(4), diff[1].Start); + Assert.Equal(start.AddHours(10), diff[1].End); + } + + [Fact] + public void Normalize_empty_input_returns_empty() + { + Assert.Empty(IntervalSetAlgebra.Normalize([])); + } + + [Fact] + public void End_before_start_is_empty_interval() + { + DateTimeOffset start = new(2026, 6, 1, 0, 0, 0, TimeSpan.Zero); + TimeInterval inverted = new(start.AddHours(2), start); + Assert.True(inverted.IsEmpty); + Assert.Empty(IntervalSetAlgebra.Normalize([inverted])); + } +} diff --git a/tests/FunctionFoundry.Scheduling.Tests/RecurringAvailabilityExtraTests.cs b/tests/FunctionFoundry.Scheduling.Tests/RecurringAvailabilityExtraTests.cs new file mode 100644 index 0000000..58b51df --- /dev/null +++ b/tests/FunctionFoundry.Scheduling.Tests/RecurringAvailabilityExtraTests.cs @@ -0,0 +1,36 @@ +namespace FunctionFoundry.Scheduling.Tests; + +public sealed class RecurringAvailabilityExtraTests +{ + [Fact] + public void Minimum_duration_filters_short_windows() + { + TimeZoneInfo utc = TimeZoneInfo.Utc; + var resolver = new RecurringAvailabilityResolver(); + AvailabilityParticipant[] participants = + [ + new("a", utc, [new RecurringLocalWindow(DayOfWeek.Wednesday, new TimeOnly(9, 0), new TimeOnly(10, 0))], []), + ]; + DateTimeOffset rangeStart = new(2026, 7, 1, 0, 0, 0, TimeSpan.Zero); + IReadOnlyList ranked = resolver.Resolve(participants, rangeStart, TimeSpan.FromHours(2)); + Assert.Empty(ranked); + } + + [Fact] + public void Null_participants_throw() + { + var resolver = new RecurringAvailabilityResolver(); + Assert.Throws(() => resolver.Resolve(null!, DateTimeOffset.UtcNow, TimeSpan.FromHours(1))); + } + + [Fact] + public void Non_positive_minimum_duration_throws() + { + var resolver = new RecurringAvailabilityResolver(); + AvailabilityParticipant[] participants = + [ + new("a", TimeZoneInfo.Utc, [new RecurringLocalWindow(DayOfWeek.Monday, new TimeOnly(9, 0), new TimeOnly(17, 0))], []), + ]; + Assert.Throws(() => resolver.Resolve(participants, DateTimeOffset.UtcNow, TimeSpan.Zero)); + } +} diff --git a/tests/FunctionFoundry.Security.Tests/EnvelopeEncryptorExtraTests.cs b/tests/FunctionFoundry.Security.Tests/EnvelopeEncryptorExtraTests.cs new file mode 100644 index 0000000..35ff7a7 --- /dev/null +++ b/tests/FunctionFoundry.Security.Tests/EnvelopeEncryptorExtraTests.cs @@ -0,0 +1,34 @@ +using System.Security.Cryptography; + +namespace FunctionFoundry.Security.Tests; + +public sealed class EnvelopeEncryptorExtraTests +{ + [Fact] + public void Empty_plaintext_round_trips() + { + byte[] key = RandomNumberGenerator.GetBytes(32); + var encryptor = new EnvelopeEncryptor(new DictionaryDataKeyResolver(new Dictionary { ["k"] = key })); + byte[] envelope = encryptor.Encrypt("k", ReadOnlySpan.Empty, ReadOnlySpan.Empty); + byte[] plain = encryptor.Decrypt(envelope, ReadOnlySpan.Empty); + Assert.Empty(plain); + } + + [Fact] + public void Wrong_key_length_is_rejected() + { + var resolver = new DictionaryDataKeyResolver(new Dictionary { ["k"] = new byte[16] }); + var encryptor = new EnvelopeEncryptor(resolver); + Assert.ThrowsAny(() => encryptor.Encrypt("k", "x"u8, ReadOnlySpan.Empty)); + } + + [Fact] + public void Magic_mismatch_is_rejected() + { + byte[] key = RandomNumberGenerator.GetBytes(32); + var encryptor = new EnvelopeEncryptor(new DictionaryDataKeyResolver(new Dictionary { ["k"] = key })); + byte[] envelope = encryptor.Encrypt("k", "x"u8, ReadOnlySpan.Empty); + envelope[0] ^= 0xFF; + Assert.Throws(() => encryptor.Decrypt(envelope, ReadOnlySpan.Empty)); + } +} diff --git a/tests/FunctionFoundry.Security.Tests/PseudonymizerExtraTests.cs b/tests/FunctionFoundry.Security.Tests/PseudonymizerExtraTests.cs new file mode 100644 index 0000000..6e6f966 --- /dev/null +++ b/tests/FunctionFoundry.Security.Tests/PseudonymizerExtraTests.cs @@ -0,0 +1,24 @@ +using System.Security.Cryptography; + +namespace FunctionFoundry.Security.Tests; + +public sealed class PseudonymizerExtraTests +{ + [Fact] + public void Base64Url_encoding_is_supported() + { + byte[] key = RandomNumberGenerator.GetBytes(32); + var p = new Pseudonymizer(key, new PseudonymizerOptions("c", "t", 1, PseudonymEncoding.Base64Url)); + string token = p.Pseudonymize("abc"u8); + Assert.StartsWith("v1.", token, StringComparison.Ordinal); + Assert.DoesNotContain('+', token); + Assert.DoesNotContain('/', token); + Assert.True(p.Verify(token, "abc"u8)); + } + + [Fact] + public void Short_key_is_rejected() + { + Assert.Throws(() => new Pseudonymizer(new byte[8], new PseudonymizerOptions("c", "t"))); + } +} diff --git a/tests/FunctionFoundry.Storage.Tests/MerkleFileTreeTests.cs b/tests/FunctionFoundry.Storage.Tests/MerkleFileTreeTests.cs index c00ed44..9b322bb 100644 --- a/tests/FunctionFoundry.Storage.Tests/MerkleFileTreeTests.cs +++ b/tests/FunctionFoundry.Storage.Tests/MerkleFileTreeTests.cs @@ -128,6 +128,16 @@ await tree.SnapshotAsync(baselineRoot), Assert.Single(diff.Modified); } + [Fact] + public async Task Snapshot_empty_directory_has_no_entries() + { + string root = CreateTempDirectory(); + var tree = new MerkleFileTree(); + MerkleFileTreeSnapshot snapshot = await tree.SnapshotAsync(root); + Assert.Empty(snapshot.Entries); + Assert.Equal(64, snapshot.RootHashHex.Length); + } + private static string CreateSampleTree() { string root = CreateTempDirectory(); diff --git a/tests/FunctionFoundry.Storage.Tests/TransactionalFileSetWriterTests.cs b/tests/FunctionFoundry.Storage.Tests/TransactionalFileSetWriterTests.cs index 9947c1a..f0ff213 100644 --- a/tests/FunctionFoundry.Storage.Tests/TransactionalFileSetWriterTests.cs +++ b/tests/FunctionFoundry.Storage.Tests/TransactionalFileSetWriterTests.cs @@ -144,6 +144,14 @@ public async Task RecoverAll_processes_multiple_transactions_in_order() Assert.True(string.CompareOrdinal(reports[0].TransactionId, reports[1].TransactionId) < 0); } + [Fact] + public async Task RecoverAll_returns_empty_when_staging_missing() + { + string root = CreateTempDirectory(); + IReadOnlyList reports = await TransactionalFileSetRecovery.RecoverAllAsync(root); + Assert.Empty(reports); + } + private static string CreateTempDirectory() { string path = Path.Combine(Path.GetTempPath(), "ff-storage-" + Guid.NewGuid().ToString("N")); diff --git a/tests/FunctionFoundry.Text.Tests/SecretCandidateScannerExtraTests.cs b/tests/FunctionFoundry.Text.Tests/SecretCandidateScannerExtraTests.cs new file mode 100644 index 0000000..b3d53b1 --- /dev/null +++ b/tests/FunctionFoundry.Text.Tests/SecretCandidateScannerExtraTests.cs @@ -0,0 +1,17 @@ +namespace FunctionFoundry.Text.Tests; + +public sealed class SecretCandidateScannerExtraTests +{ + [Fact] + public void Empty_text_returns_no_findings() + { + Assert.Empty(new SecretCandidateScanner().Scan(string.Empty)); + } + + [Fact] + public void Low_entropy_words_are_not_reported_as_secrets() + { + IReadOnlyList findings = new SecretCandidateScanner().Scan("the quick brown fox jumps over the lazy dog"); + Assert.DoesNotContain(findings, f => f.Confidence > 0.9); + } +} diff --git a/tests/FunctionFoundry.Text.Tests/UnicodeSpoofExtraTests.cs b/tests/FunctionFoundry.Text.Tests/UnicodeSpoofExtraTests.cs new file mode 100644 index 0000000..15f485a --- /dev/null +++ b/tests/FunctionFoundry.Text.Tests/UnicodeSpoofExtraTests.cs @@ -0,0 +1,24 @@ +namespace FunctionFoundry.Text.Tests; + +public sealed class UnicodeSpoofExtraTests +{ + [Fact] + public void Empty_input_returns_no_findings() + { + UnicodeSpoofAnalysisResult result = new UnicodeSpoofDetector().Analyze(string.Empty); + Assert.Empty(result.Findings); + } + + [Fact] + public void Ascii_plain_text_has_no_spoof_findings() + { + UnicodeSpoofAnalysisResult result = new UnicodeSpoofDetector().Analyze("paypal"); + Assert.DoesNotContain(result.Findings, f => f.Kind == UnicodeSpoofRiskKind.ConfusableCharacter); + } + + [Fact] + public void Null_input_throws() + { + Assert.ThrowsAny(() => new UnicodeSpoofDetector().Analyze(null!)); + } +}