diff --git a/backend/tests/Taskdeck.Application.Tests/Fuzz/ExportImportContractFuzzTests.cs b/backend/tests/Taskdeck.Application.Tests/Fuzz/ExportImportContractFuzzTests.cs new file mode 100644 index 000000000..3db6d893f --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Fuzz/ExportImportContractFuzzTests.cs @@ -0,0 +1,295 @@ +using System.Text.Json; +using FluentAssertions; +using FsCheck; +using FsCheck.Xunit; +using Taskdeck.Application.DTOs; + +namespace Taskdeck.Application.Tests.Fuzz; + +/// +/// Fuzz-style tests for export/import DTO serialization contracts. +/// Verifies JSON roundtrip fidelity and that deserialization of arbitrary payloads +/// never throws unhandled exceptions. +/// Replay: set Replay = "seed,size" on any [Property] to reproduce a failing case. +/// +public class ExportImportContractFuzzTests +{ + private const int MaxTests = 200; + + private static readonly JsonSerializerOptions CamelCaseOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true + }; + + [Property(MaxTest = MaxTests)] + public Property ImportBoardDto_Roundtrip_PreservesData() + { + return Prop.ForAll( + ValidImportBoardDtoArb(), + dto => + { + var json = JsonSerializer.Serialize(dto, CamelCaseOptions); + var deserialized = JsonSerializer.Deserialize(json, CamelCaseOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Name.Should().Be(dto.Name); + deserialized.Description.Should().Be(dto.Description); + deserialized.Columns.Count().Should().Be(dto.Columns.Count()); + deserialized.Cards.Count().Should().Be(dto.Cards.Count()); + deserialized.Labels.Count().Should().Be(dto.Labels.Count()); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ImportBoardDto_Deserialize_NeverThrows_OnArbitraryJson() + { + return Prop.ForAll( + Arb.From(), + json => + { + // Deserialization of arbitrary strings should either succeed or throw JsonException, + // never an unhandled exception type + try + { + JsonSerializer.Deserialize(json ?? "null", CamelCaseOptions); + } + catch (JsonException) + { + // Expected for malformed JSON + } + catch (Exception ex) + { + // Any other exception type is a test failure + ex.Should().BeNull($"Unexpected exception type {ex.GetType()}: {ex.Message}"); + } + }); + } + + [Property(MaxTest = MaxTests)] + public Property ImportColumnDto_Roundtrip_PreservesData() + { + return Prop.ForAll( + ValidImportColumnDtoArb(), + dto => + { + var json = JsonSerializer.Serialize(dto, CamelCaseOptions); + var deserialized = JsonSerializer.Deserialize(json, CamelCaseOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Name.Should().Be(dto.Name); + deserialized.Position.Should().Be(dto.Position); + deserialized.WipLimit.Should().Be(dto.WipLimit); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ImportCardDto_Roundtrip_PreservesData() + { + return Prop.ForAll( + ValidImportCardDtoArb(), + dto => + { + var json = JsonSerializer.Serialize(dto, CamelCaseOptions); + var deserialized = JsonSerializer.Deserialize(json, CamelCaseOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Title.Should().Be(dto.Title); + deserialized.Description.Should().Be(dto.Description); + deserialized.ColumnName.Should().Be(dto.ColumnName); + deserialized.Position.Should().Be(dto.Position); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ImportLabelDto_Roundtrip_PreservesData() + { + return Prop.ForAll( + ValidImportLabelDtoArb(), + dto => + { + var json = JsonSerializer.Serialize(dto, CamelCaseOptions); + var deserialized = JsonSerializer.Deserialize(json, CamelCaseOptions); + + deserialized.Should().NotBeNull(); + deserialized!.Name.Should().Be(dto.Name); + deserialized.Color.Should().Be(dto.Color); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ImportBoardDto_ExtraFields_IgnoredGracefully() + { + // Verify that extra/unknown fields in JSON don't break deserialization + return Prop.ForAll( + Arb.From(Gen.OneOf( + Gen.Constant("{\"name\":\"Test\",\"unknownField\":42,\"columns\":[],\"cards\":[],\"labels\":[]}"), + Gen.Constant("{\"name\":\"Test\",\"description\":\"Desc\",\"extra\":{\"nested\":true},\"columns\":[],\"cards\":[],\"labels\":[]}"), + Gen.Constant("{\"name\":\"Test\",\"columns\":[{\"name\":\"Col\",\"position\":0,\"extra\":true}],\"cards\":[],\"labels\":[]}") + )), + json => + { + var act = () => JsonSerializer.Deserialize(json, CamelCaseOptions); + act.Should().NotThrow("extra fields should be silently ignored"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ImportBoardDto_MissingFields_HandledGracefully() + { + return Prop.ForAll( + Arb.From(Gen.OneOf( + Gen.Constant("{}"), + Gen.Constant("{\"name\":null}"), + Gen.Constant("{\"name\":\"Test\"}"), + Gen.Constant("{\"columns\":[]}"), + Gen.Constant("{\"name\":\"\",\"columns\":null,\"cards\":null,\"labels\":null}") + )), + json => + { + // Should deserialize without throwing (fields may be null/default) + var act = () => JsonSerializer.Deserialize(json, CamelCaseOptions); + act.Should().NotThrow("missing fields should result in defaults, not exceptions"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property StarterPackManifestDto_Roundtrip_PreservesData() + { + return Prop.ForAll( + ValidStarterPackDtoArb(), + dto => + { + var json = JsonSerializer.Serialize(dto, CamelCaseOptions); + var deserialized = JsonSerializer.Deserialize(json, CamelCaseOptions); + + deserialized.Should().NotBeNull(); + deserialized!.SchemaVersion.Should().Be(dto.SchemaVersion); + deserialized.PackId.Should().Be(dto.PackId); + deserialized.DisplayName.Should().Be(dto.DisplayName); + deserialized.Labels.Count.Should().Be(dto.Labels.Count); + deserialized.Columns.Count.Should().Be(dto.Columns.Count); + }); + } + + private static Arbitrary ValidImportBoardDtoArb() + { + var gen = Gen.Choose(0, 5).SelectMany(colCount => + Gen.Choose(0, 5).SelectMany(cardCount => + Gen.Choose(0, 3).Select(labelCount => + { + var columns = Enumerable.Range(0, colCount) + .Select(i => new ImportColumnDto($"Column{i}", i, i > 0 ? i * 3 : (int?)null)) + .ToList(); + + var columnNames = columns.Select(c => c.Name).ToArray(); + + var cards = Enumerable.Range(0, cardCount) + .Select(i => new ImportCardDto( + $"Card{i}", + $"Description for card {i}", + columnNames.Length > 0 ? columnNames[i % columnNames.Length] : "Default", + i, + i % 2 == 0 ? DateTimeOffset.UtcNow.AddDays(i) : null, + null)) + .ToList(); + + var labels = Enumerable.Range(0, labelCount) + .Select(i => + { + var hex = ((i + 1) * 55 % 256).ToString("X2"); + return new ImportLabelDto($"Label{i}", $"#{hex}{hex}{hex}"); + }) + .ToList(); + + return new ImportBoardDto( + $"Board-{colCount}-{cardCount}", + "Test board description", + columns, + cards, + labels); + }))); + return Arb.From(gen); + } + + private static Arbitrary ValidImportColumnDtoArb() + { + var gen = Gen.Choose(0, 100).SelectMany(pos => + Gen.OneOf( + Gen.Constant((int?)null), + Gen.Choose(1, 50).Select(v => (int?)v) + ).Select(wipLimit => + new ImportColumnDto($"Column-{pos}", pos, wipLimit))); + return Arb.From(gen); + } + + private static Arbitrary ValidImportCardDtoArb() + { + var gen = Gen.Choose(0, 100).Select(pos => + new ImportCardDto( + $"Card-{pos}", + $"Description for card {pos}", + "Default", + pos, + pos % 2 == 0 ? DateTimeOffset.UtcNow.AddDays(pos) : null, + pos % 3 == 0 ? new[] { "Label1" } : null)); + return Arb.From(gen); + } + + private static Arbitrary ValidImportLabelDtoArb() + { + var gen = Gen.Choose(0, 255).Select(i => + { + var hex = i.ToString("X2"); + return new ImportLabelDto($"Label-{i}", $"#{hex}{hex}{hex}"); + }); + return Arb.From(gen); + } + + private static Arbitrary ValidStarterPackDtoArb() + { + var gen = Gen.Choose(1, 3).SelectMany(labelCount => + Gen.Choose(1, 4).Select(colCount => + { + var labels = Enumerable.Range(0, labelCount) + .Select(i => + { + var hex = ((i + 1) * 37 % 256).ToString("X2"); + return new StarterPackLabelDto + { + Name = $"Label{i}", + Color = $"#{hex}{hex}{hex}" + }; + }) + .ToList(); + + var columns = Enumerable.Range(0, colCount) + .Select(i => new StarterPackColumnDto + { + Name = $"Column{i}", + Position = i, + WipLimit = i > 0 ? i * 5 : null + }) + .ToList(); + + return new StarterPackManifestDto + { + SchemaVersion = "1.0", + PackId = "test-pack", + DisplayName = "Test Pack", + Description = "Fuzz test pack", + Compatibility = new StarterPackCompatibilityDto + { + MinTaskdeckVersion = "0.1.0", + RequiredFeatures = new List() + }, + Tags = new List { "test" }, + Labels = labels, + Columns = columns, + Templates = new List(), + SeedCards = new List() + }; + })); + return Arb.From(gen); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Fuzz/LlmIntentClassifierFuzzTests.cs b/backend/tests/Taskdeck.Application.Tests/Fuzz/LlmIntentClassifierFuzzTests.cs new file mode 100644 index 000000000..d2272314a --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Fuzz/LlmIntentClassifierFuzzTests.cs @@ -0,0 +1,217 @@ +using FluentAssertions; +using FsCheck; +using FsCheck.Xunit; +using Taskdeck.Application.Services; + +namespace Taskdeck.Application.Tests.Fuzz; + +/// +/// Fuzz-style tests for LlmIntentClassifier. +/// Verifies that the classifier never throws unhandled exceptions regardless of input, +/// and that its regex patterns handle adversarial/pathological strings safely. +/// Replay: set Replay = "seed,size" on any [Property] to reproduce a failing case. +/// +public class LlmIntentClassifierFuzzTests +{ + private const int MaxTests = 300; + + [Property(MaxTest = MaxTests)] + public Property Classify_NeverThrows_OnArbitraryString() + { + return Prop.ForAll( + Arb.From(), + input => + { + var act = () => LlmIntentClassifier.Classify(input); + act.Should().NotThrow("Classify must handle all string inputs gracefully"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property Classify_AlwaysReturnsTuple() + { + return Prop.ForAll( + Arb.From(), + input => + { + var (isActionable, actionIntent) = LlmIntentClassifier.Classify(input); + if (!isActionable) + { + actionIntent.Should().BeNull("non-actionable results should have null intent"); + } + else + { + actionIntent.Should().NotBeNullOrEmpty("actionable results must have an intent"); + } + }); + } + + [Property(MaxTest = MaxTests)] + public Property Classify_EmptyAndWhitespace_AlwaysNonActionable() + { + return Prop.ForAll( + Arb.From(Gen.Elements("", " ", "\t", "\n", "\r\n", " ", null!)), + input => + { + var (isActionable, _) = LlmIntentClassifier.Classify(input); + isActionable.Should().BeFalse("empty/whitespace input should not be actionable"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property Classify_NeverThrows_OnLongInput() + { + return Prop.ForAll( + Arb.From(Gen.Choose(1000, 10000).Select(len => new string('a', len))), + longInput => + { + var act = () => LlmIntentClassifier.Classify(longInput); + act.Should().NotThrow("long inputs must not cause timeout or crash"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property Classify_NeverThrows_OnRepeatedPatternInput() + { + // Strings that could trigger catastrophic backtracking in naive regex + return Prop.ForAll( + Arb.From(Gen.OneOf( + Gen.Choose(50, 500).Select(len => new string('a', len) + " card"), + Gen.Choose(50, 500).Select(len => string.Concat(Enumerable.Repeat("word ", len)) + "card"), + Gen.Choose(50, 200).Select(len => string.Concat(Enumerable.Repeat("create ", len))), + Gen.Choose(50, 200).Select(len => string.Concat(Enumerable.Repeat("don't ", len)) + "create a card") + )), + pathological => + { + var act = () => LlmIntentClassifier.Classify(pathological); + act.Should().NotThrow("pathological patterns must not cause regex backtracking issues"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property Classify_ActionableIntent_AlwaysHasKnownPrefix() + { + // Known intents: card.create, card.move, card.archive, card.update, + // board.create, board.update, column.reorder + var knownPrefixes = new[] { "card.", "board.", "column." }; + + return Prop.ForAll( + ActionableInputArb(), + input => + { + var (isActionable, actionIntent) = LlmIntentClassifier.Classify(input); + if (isActionable && actionIntent != null) + { + actionIntent.Should().Match( + intent => knownPrefixes.Any(prefix => intent.StartsWith(prefix)), + "actionable intents must start with a known entity prefix"); + } + }); + } + + [Property(MaxTest = MaxTests)] + public Property Classify_NegatedInput_NeverActionable() + { + // Tests negation patterns that the classifier currently handles. + // Known gap: verb forms like "adding" (gerund) are not matched by the + // negation regex which requires bare infinitives (add, create, move, etc.). + // "avoid adding new tasks" is classified as actionable because "adding" + // doesn't match \b(create|add|...)\b in the negation pattern, but "new tasks" + // matches the NewCardPattern. Filed as a finding — see PR description. + return Prop.ForAll( + NegatedInputArb(), + input => + { + var (isActionable, _) = LlmIntentClassifier.Classify(input); + isActionable.Should().BeFalse( + $"negated input '{input}' should not be actionable"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property Classify_NeverThrows_OnUnicodeInput() + { + return Prop.ForAll( + UnicodeInputArb(), + input => + { + var act = () => LlmIntentClassifier.Classify(input); + act.Should().NotThrow("unicode input must not cause exceptions"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property Classify_NeverThrows_OnSpecialCharacters() + { + return Prop.ForAll( + Arb.From(Gen.OneOf( + Gen.Constant("create a card with "), + Gen.Constant("create a card with'; DROP TABLE cards; --"), + Gen.Constant("create a card\0with null bytes"), + Gen.Constant("create\r\na\r\ncard"), + Gen.Constant("create a card\twith\ttabs"), + Gen.Constant("CREATE A CARD IN ALL CAPS"), + Gen.Constant("CrEaTe A cArD iN mIxEd CaSe") + )), + input => + { + var act = () => LlmIntentClassifier.Classify(input); + act.Should().NotThrow("special characters must not cause exceptions"); + }); + } + + /// + /// Generates inputs known to be actionable. + /// + private static Arbitrary ActionableInputArb() + { + return Arb.From(Gen.OneOf( + Gen.Constant("create a new card called test"), + Gen.Constant("add a task for the meeting"), + Gen.Constant("move card to done column"), + Gen.Constant("archive the old task"), + Gen.Constant("delete card number 5"), + Gen.Constant("update card title to new name"), + Gen.Constant("rename task to better name"), + Gen.Constant("create a new board for the project"), + Gen.Constant("rename board to Sprint 42"), + Gen.Constant("reorder columns on the board") + )); + } + + /// + /// Generates negated inputs that should be classified as non-actionable. + /// Uses bare infinitive verbs that match the negation regex pattern. + /// Note: gerund forms (e.g., "avoid adding") are a known gap — the negation + /// pattern only matches bare infinitives (add, create, move, etc.). + /// + private static Arbitrary NegatedInputArb() + { + return Arb.From(Gen.OneOf( + Gen.Constant("don't create a card yet"), + Gen.Constant("do not add a task"), + Gen.Constant("never move the card"), + Gen.Constant("stop create new cards"), + Gen.Constant("cancel the delete of the card"), + Gen.Constant("don't add new tasks"), + Gen.Constant("do not create a board"), + Gen.Constant("never add another task") + )); + } + + /// + /// Generates unicode strings for robustness testing. + /// + private static Arbitrary UnicodeInputArb() + { + return Arb.From(Gen.OneOf( + Gen.Constant("create a card"), + Gen.Constant("\u00e9\u00e8\u00ea\u00eb create a card"), + Gen.Constant("create \u4e00\u4e8c\u4e09 card"), + Gen.Constant("\u0410\u0411\u0412 create card"), + Gen.Constant("create a card \ud83d\ude00\ud83d\ude01\ud83d\ude02"), + Gen.Constant("\u200b\u200c\u200d create a card"), + Gen.Constant("create\u00a0a\u00a0card") // non-breaking spaces + )); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Fuzz/StarterPackManifestFuzzTests.cs b/backend/tests/Taskdeck.Application.Tests/Fuzz/StarterPackManifestFuzzTests.cs new file mode 100644 index 000000000..02fccbeca --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Fuzz/StarterPackManifestFuzzTests.cs @@ -0,0 +1,253 @@ +using System.Text.Json; +using FluentAssertions; +using FsCheck; +using FsCheck.Xunit; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Services; + +namespace Taskdeck.Application.Tests.Fuzz; + +/// +/// Fuzz-style tests for StarterPackManifestValidator. +/// These tests verify that the validator never throws unhandled exceptions +/// regardless of input, and that well-formed manifests always validate successfully. +/// Replay: set Replay = "seed,size" on any [Property] to reproduce a failing case. +/// +public class StarterPackManifestFuzzTests +{ + private const int MaxTests = 200; + private readonly StarterPackManifestValidator _validator = new(); + + [Property(MaxTest = MaxTests)] + public Property ValidateJson_NeverThrows_OnArbitraryString() + { + return Prop.ForAll( + Arb.From(), + input => + { + // The validator should gracefully handle any string input + // without throwing unhandled exceptions + var act = () => _validator.ValidateJson(input); + act.Should().NotThrow("ValidateJson must handle all inputs gracefully"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ValidateJson_NeverThrows_OnMalformedJson() + { + return Prop.ForAll( + MalformedJsonArb(), + malformed => + { + var act = () => _validator.ValidateJson(malformed); + act.Should().NotThrow("ValidateJson must handle malformed JSON gracefully"); + var result = _validator.ValidateJson(malformed); + result.IsValid.Should().BeFalse("malformed JSON should not validate"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ValidateJson_EmptyAndWhitespace_ReturnError() + { + return Prop.ForAll( + Arb.From(Gen.Elements("", " ", "\t", "\n", "\r\n", null!)), + input => + { + var result = _validator.ValidateJson(input); + result.IsValid.Should().BeFalse(); + result.Errors.Should().NotBeEmpty(); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ValidateJson_ValidManifestJson_AlwaysSucceeds() + { + return Prop.ForAll( + ValidManifestJsonArb(), + json => + { + var result = _validator.ValidateJson(json); + result.IsValid.Should().BeTrue( + $"A well-formed manifest should validate successfully. Errors: " + + $"{string.Join("; ", result.Errors.Select(e => $"{e.Path}: {e.Message}"))}"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property Validate_NullManifest_ReturnsError() + { + var result = _validator.Validate(null!); + result.IsValid.Should().BeFalse(); + result.Errors.Should().ContainSingle(); + return true.ToProperty(); + } + + [Property(MaxTest = MaxTests)] + public Property Validate_NeverThrows_OnRandomDto() + { + return Prop.ForAll( + RandomManifestDtoArb(), + dto => + { + var act = () => _validator.Validate(dto); + act.Should().NotThrow("Validate must handle all DTO shapes gracefully"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ValidateJson_TruncatedJson_NeverThrows() + { + return Prop.ForAll( + ValidManifestJsonArb().Generator + .SelectMany(json => + Gen.Choose(1, Math.Max(1, json.Length - 1)) + .Select(cutoff => json[..cutoff])) + .ToArbitrary(), + truncated => + { + var act = () => _validator.ValidateJson(truncated); + act.Should().NotThrow("truncated JSON must be handled gracefully"); + var result = _validator.ValidateJson(truncated); + result.IsValid.Should().BeFalse("truncated JSON should not validate"); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ValidateJson_NullInjectedFields_NeverThrows() + { + return Prop.ForAll( + NullFieldManifestJsonArb(), + json => + { + var act = () => _validator.ValidateJson(json); + act.Should().NotThrow("null-injected JSON must be handled gracefully"); + }); + } + + /// + /// Generates malformed JSON strings that should fail parsing. + /// + private static Arbitrary MalformedJsonArb() + { + return Arb.From(Gen.OneOf( + Gen.Constant("{"), + Gen.Constant("}"), + Gen.Constant("["), + Gen.Constant("{\"schemaVersion\":"), + Gen.Constant("{\"schemaVersion\": \"1.0\", columns: invalid}"), + Gen.Constant("not json at all"), + Gen.Constant("not json"), + Gen.Constant("{\"schemaVersion\": \"1.0\""), + Gen.Constant("42"), + Gen.Constant("true"), + Gen.Constant("null"), + Gen.Constant("\"just a string\""), + Gen.Constant("{\"deeply\": {\"nested\": {\"but\": \"wrong\"}}}"), + Gen.Constant("[]"), + Gen.Constant("[1,2,3]") + )); + } + + /// + /// Generates valid starter-pack manifest JSON. + /// + private static Arbitrary ValidManifestJsonArb() + { + var gen = Gen.Choose(1, 5).SelectMany(labelCount => + Gen.Choose(1, 5).SelectMany(columnCount => + { + var labels = Enumerable.Range(0, labelCount) + .Select(i => new StarterPackLabelDto + { + Name = $"Label{i}", + Color = $"#{i:D2}{i:D2}{i:D2}".Replace("00", "AA") + }) + .ToList(); + + // Fix label colors to valid hex + for (var i = 0; i < labels.Count; i++) + { + var hexByte = ((i + 1) * 37 % 256).ToString("X2"); + labels[i].Color = $"#{hexByte}{hexByte}{hexByte}"; + } + + var columns = Enumerable.Range(0, columnCount) + .Select(i => new StarterPackColumnDto + { + Name = $"Column{i}", + Position = i, + WipLimit = i > 0 ? i * 5 : null + }) + .ToList(); + + var manifest = new StarterPackManifestDto + { + SchemaVersion = "1.0", + PackId = "test-pack", + DisplayName = "Test Pack", + Description = "A test manifest", + Compatibility = new StarterPackCompatibilityDto + { + MinTaskdeckVersion = "0.1.0", + RequiredFeatures = new List() + }, + Tags = new List { "test" }, + Labels = labels, + Columns = columns, + Templates = new List(), + SeedCards = new List() + }; + + return Gen.Constant(JsonSerializer.Serialize(manifest, new JsonSerializerOptions + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + })); + })); + + return Arb.From(gen); + } + + /// + /// Generates random StarterPackManifestDto with arbitrary field values. + /// + private static Arbitrary RandomManifestDtoArb() + { + var gen = Arb.From().Generator.SelectMany(schemaVer => + Arb.From().Generator.SelectMany(packId => + Arb.From().Generator.Select(displayName => + { + var dto = new StarterPackManifestDto + { + SchemaVersion = schemaVer ?? "", + PackId = packId ?? "", + DisplayName = displayName ?? "", + Description = null, + Compatibility = new StarterPackCompatibilityDto + { + MinTaskdeckVersion = schemaVer ?? "", + RequiredFeatures = new List() + }, + Tags = new List(), + Labels = new List(), + Columns = new List(), + Templates = new List(), + SeedCards = new List() + }; + return dto; + }))); + return Arb.From(gen); + } + + /// + /// Generates JSON with null-injected fields to test null-safety. + /// + private static Arbitrary NullFieldManifestJsonArb() + { + return Arb.From(Gen.OneOf( + Gen.Constant("{\"schemaVersion\":null,\"packId\":null,\"displayName\":null,\"compatibility\":null,\"tags\":null,\"labels\":null,\"columns\":null,\"templates\":null,\"seedCards\":null}"), + Gen.Constant("{\"schemaVersion\":\"1.0\",\"packId\":\"test\",\"displayName\":\"Test\",\"compatibility\":null,\"tags\":[],\"labels\":[],\"columns\":[{\"name\":\"Col\",\"position\":0}],\"templates\":[],\"seedCards\":[]}"), + Gen.Constant("{\"schemaVersion\":\"1.0\",\"packId\":\"test\",\"displayName\":null,\"tags\":[null],\"labels\":[null],\"columns\":[null],\"templates\":[null],\"seedCards\":[null]}"), + Gen.Constant("{\"schemaVersion\":\"1.0\",\"packId\":\"test\",\"displayName\":\"Test\",\"compatibility\":{\"minTaskdeckVersion\":null,\"requiredFeatures\":null},\"tags\":[],\"labels\":[],\"columns\":[{\"name\":\"Col\",\"position\":0}],\"templates\":[],\"seedCards\":[]}") + )); + } +} diff --git a/backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj b/backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj index 0c2a56770..b1c75edac 100644 --- a/backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj +++ b/backend/tests/Taskdeck.Application.Tests/Taskdeck.Application.Tests.csproj @@ -20,6 +20,8 @@ all + + diff --git a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/AutomationProposalPropertyTests.cs b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/AutomationProposalPropertyTests.cs new file mode 100644 index 000000000..8dc175ff5 --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/AutomationProposalPropertyTests.cs @@ -0,0 +1,247 @@ +using FluentAssertions; +using FsCheck; +using FsCheck.Xunit; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Domain.Tests.PropertyBased; + +/// +/// Property-based tests for AutomationProposal entity state machine and invariants. +/// Replay: set Replay = "seed,size" on any [Property] to reproduce a failing case. +/// +public class AutomationProposalPropertyTests +{ + private const int MaxTests = 200; + + [Property(MaxTest = MaxTests)] + public Property ValidConstruction_AlwaysCreatesPendingProposal() + { + return Prop.ForAll( + ValidSummaryArb(), + Arb.From(Gen.Elements( + ProposalSourceType.Queue, ProposalSourceType.Chat, ProposalSourceType.Manual)), + Arb.From(Gen.Elements( + RiskLevel.Low, RiskLevel.Medium, RiskLevel.High, RiskLevel.Critical)), + (summary, sourceType, riskLevel) => + { + var userId = Guid.NewGuid(); + var correlationId = Guid.NewGuid().ToString(); + var proposal = new AutomationProposal(sourceType, userId, summary, riskLevel, correlationId); + proposal.Status.Should().Be(ProposalStatus.PendingReview); + proposal.Summary.Should().Be(summary); + proposal.SourceType.Should().Be(sourceType); + proposal.RiskLevel.Should().Be(riskLevel); + proposal.RequestedByUserId.Should().Be(userId); + }); + } + + [Fact] + public void EmptyUserId_AlwaysThrows() + { + var act = () => new AutomationProposal( + ProposalSourceType.Queue, Guid.Empty, "Valid summary", + RiskLevel.Low, Guid.NewGuid().ToString()); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + } + + [Property(MaxTest = MaxTests)] + public Property EmptyOrWhitespaceSummary_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements("", " ", "\t", "\n")), + summary => + { + var act = () => new AutomationProposal( + ProposalSourceType.Queue, Guid.NewGuid(), summary, + RiskLevel.Low, Guid.NewGuid().ToString()); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property SummaryExceeding500Chars_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(501, 1000).Select(len => new string('s', len))), + longSummary => + { + var act = () => new AutomationProposal( + ProposalSourceType.Queue, Guid.NewGuid(), longSummary, + RiskLevel.Low, Guid.NewGuid().ToString()); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ZeroOrNegativeExpiryMinutes_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(-100, 0)), + expiryMinutes => + { + var act = () => new AutomationProposal( + ProposalSourceType.Queue, Guid.NewGuid(), "Valid", + RiskLevel.Low, Guid.NewGuid().ToString(), + expiryMinutes: expiryMinutes); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ApproveFromNonPending_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements("approved", "rejected", "expired")), + priorState => + { + var proposal = CreatePendingProposal(); + var deciderId = Guid.NewGuid(); + + // Transition to non-pending state + switch (priorState) + { + case "approved": + proposal.Approve(deciderId); + break; + case "rejected": + proposal.Reject(deciderId); + break; + case "expired": + proposal.Expire(); + break; + } + + // Attempt to approve from non-pending state + var act = () => proposal.Approve(Guid.NewGuid()); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.InvalidOperation); + }); + } + + [Property(MaxTest = MaxTests)] + public Property RejectFromNonPending_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements("approved", "rejected", "expired")), + priorState => + { + var proposal = CreatePendingProposal(); + var deciderId = Guid.NewGuid(); + + switch (priorState) + { + case "approved": + proposal.Approve(deciderId); + break; + case "rejected": + proposal.Reject(deciderId); + break; + case "expired": + proposal.Expire(); + break; + } + + var act = () => proposal.Reject(Guid.NewGuid()); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.InvalidOperation); + }); + } + + [Property(MaxTest = MaxTests)] + public Property HighRiskReject_WithoutReason_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements(RiskLevel.High, RiskLevel.Critical)), + riskLevel => + { + var proposal = CreatePendingProposal(riskLevel: riskLevel); + var act = () => proposal.Reject(Guid.NewGuid()); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property LowMediumRiskReject_WithoutReason_Succeeds() + { + return Prop.ForAll( + Arb.From(Gen.Elements(RiskLevel.Low, RiskLevel.Medium)), + riskLevel => + { + var proposal = CreatePendingProposal(riskLevel: riskLevel); + proposal.Reject(Guid.NewGuid()); + proposal.Status.Should().Be(ProposalStatus.Rejected); + }); + } + + [Fact] + public void MarkAsApplied_OnlyFromApproved() + { + var proposal = CreatePendingProposal(); + proposal.Approve(Guid.NewGuid()); + proposal.MarkAsApplied(); + proposal.Status.Should().Be(ProposalStatus.Applied); + proposal.AppliedAt.Should().NotBeNull(); + } + + [Fact] + public void MarkAsFailed_OnlyFromApproved() + { + var proposal = CreatePendingProposal(); + proposal.Approve(Guid.NewGuid()); + proposal.MarkAsFailed("Something went wrong"); + proposal.Status.Should().Be(ProposalStatus.Failed); + proposal.FailureReason.Should().Be("Something went wrong"); + } + + [Property(MaxTest = MaxTests)] + public Property MarkAsFailed_EmptyReason_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements("", " ", "\t")), + reason => + { + var proposal = CreatePendingProposal(); + proposal.Approve(Guid.NewGuid()); + var act = () => proposal.MarkAsFailed(reason); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Fact] + public void MarkAsApplied_FromPending_AlwaysThrows() + { + var proposal = CreatePendingProposal(); + var act = () => proposal.MarkAsApplied(); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.InvalidOperation); + } + + private static AutomationProposal CreatePendingProposal(RiskLevel riskLevel = RiskLevel.Low) + { + return new AutomationProposal( + ProposalSourceType.Queue, + Guid.NewGuid(), + "Test summary", + riskLevel, + Guid.NewGuid().ToString()); + } + + private static Arbitrary ValidSummaryArb() + { + var gen = Gen.Choose(1, 500) + .SelectMany(len => + Gen.ArrayOf(len, Gen.Elements( + 'a', 'b', 'c', 'A', 'B', 'C', '1', '2', '3', ' ', '-', '_')) + .Select(chars => new string(chars))) + .Where(s => !string.IsNullOrWhiteSpace(s)); + return Arb.From(gen); + } +} diff --git a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/BoardPropertyTests.cs b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/BoardPropertyTests.cs new file mode 100644 index 000000000..9577305db --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/BoardPropertyTests.cs @@ -0,0 +1,160 @@ +using FluentAssertions; +using FsCheck; +using FsCheck.Xunit; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Tests.PropertyBased; + +/// +/// Property-based tests for Board entity invariants. +/// FsCheck generates random inputs to verify domain constraints hold for all valid/invalid values. +/// Replay: set Replay = "seed,size" on any [Property] to reproduce a failing case deterministically. +/// +public class BoardPropertyTests +{ + // Runtime budget: MaxTest caps total generated cases to keep CI fast (default 100). + private const int MaxTests = 200; + + [Property(MaxTest = MaxTests)] + public Property ValidName_AlwaysCreatesBoard() + { + return Prop.ForAll( + ValidBoardNameArb(), + name => + { + var board = new Board(name); + board.Name.Should().Be(name); + board.IsArchived.Should().BeFalse(); + board.Id.Should().NotBeEmpty(); + }); + } + + [Property(MaxTest = MaxTests)] + public Property EmptyOrWhitespaceName_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements("", " ", "\t", "\n", " \t\n ")), + name => + { + var act = () => new Board(name); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NameExceeding100Chars_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(101, 500).Select(len => new string('x', len))), + longName => + { + var act = () => new Board(longName); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NameAtExactly100Chars_Succeeds() + { + var name100 = new string('a', 100); + var board = new Board(name100); + return (board.Name == name100).ToProperty(); + } + + [Property(MaxTest = MaxTests)] + public Property DescriptionExceeding1000Chars_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(1001, 2000).Select(len => new string('d', len))), + longDesc => + { + var act = () => new Board("Valid", longDesc); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ValidDescription_AlwaysAccepted() + { + return Prop.ForAll( + Arb.From(Gen.Choose(0, 1000).Select(len => new string('d', len))), + desc => + { + var board = new Board("Valid", desc); + board.Description.Should().Be(desc); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ArchiveUnarchive_IsIdempotent() + { + return Prop.ForAll( + Arb.From(Gen.Choose(1, 10)), + toggleCount => + { + var board = new Board("Test"); + for (int i = 0; i < toggleCount; i++) + { + board.Archive(); + board.IsArchived.Should().BeTrue(); + board.Unarchive(); + board.IsArchived.Should().BeFalse(); + } + }); + } + + [Property(MaxTest = MaxTests)] + public Property TransferOwnership_RejectsEmptyGuid() + { + var board = new Board("Test"); + var act = () => board.TransferOwnership(Guid.Empty); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + return true.ToProperty(); + } + + [Property(MaxTest = MaxTests)] + public Property TransferOwnership_AcceptsAnyNonEmptyGuid() + { + return Prop.ForAll( + Arb.From(Gen.Fresh(() => Guid.NewGuid())), + newOwnerId => + { + var board = new Board("Test", ownerId: Guid.NewGuid()); + board.TransferOwnership(newOwnerId); + board.OwnerId.Should().Be(newOwnerId); + }); + } + + [Property(MaxTest = MaxTests)] + public Property Update_PreservesUnchangedFields() + { + return Prop.ForAll( + ValidBoardNameArb(), + name => + { + var board = new Board("Original", "Desc"); + board.Update(name: name); + board.Name.Should().Be(name); + board.Description.Should().Be("Desc"); // unchanged + }); + } + + /// + /// Custom Arbitrary that generates valid board names: 1-100 non-whitespace-only chars. + /// + private static Arbitrary ValidBoardNameArb() + { + var gen = Gen.Choose(1, 100) + .SelectMany(len => + Gen.ArrayOf(len, Gen.Elements( + 'a', 'b', 'c', 'A', 'B', 'C', '1', '2', '3', ' ', '-', '_')) + .Select(chars => new string(chars))) + .Where(s => !string.IsNullOrWhiteSpace(s)); + return Arb.From(gen); + } +} diff --git a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/CardPropertyTests.cs b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/CardPropertyTests.cs new file mode 100644 index 000000000..34ceb595f --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/CardPropertyTests.cs @@ -0,0 +1,171 @@ +using FluentAssertions; +using FsCheck; +using FsCheck.Xunit; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Tests.PropertyBased; + +/// +/// Property-based tests for Card entity invariants. +/// Replay: set Replay = "seed,size" on any [Property] to reproduce a failing case. +/// +public class CardPropertyTests +{ + private const int MaxTests = 200; + + private static readonly Guid TestBoardId = Guid.NewGuid(); + private static readonly Guid TestColumnId = Guid.NewGuid(); + + [Property(MaxTest = MaxTests)] + public Property ValidTitle_AlwaysCreatesCard() + { + return Prop.ForAll( + ValidCardTitleArb(), + title => + { + var card = new Card(TestBoardId, TestColumnId, title); + card.Title.Should().Be(title); + card.BoardId.Should().Be(TestBoardId); + card.ColumnId.Should().Be(TestColumnId); + card.IsBlocked.Should().BeFalse(); + card.Position.Should().Be(0); + }); + } + + [Property(MaxTest = MaxTests)] + public Property EmptyOrWhitespaceTitle_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements("", " ", "\t", "\n", " \t\n ")), + title => + { + var act = () => new Card(TestBoardId, TestColumnId, title); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property TitleExceeding200Chars_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(201, 500).Select(len => new string('x', len))), + longTitle => + { + var act = () => new Card(TestBoardId, TestColumnId, longTitle); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property DescriptionExceeding2000Chars_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(2001, 3000).Select(len => new string('d', len))), + longDesc => + { + var act = () => new Card(TestBoardId, TestColumnId, "Valid", longDesc); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NegativePosition_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(-1000, -1)), + negPos => + { + var card = new Card(TestBoardId, TestColumnId, "Valid"); + var act = () => card.SetPosition(negPos); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NonNegativePosition_AlwaysAccepted() + { + return Prop.ForAll( + Arb.From(Gen.Choose(0, 10000)), + pos => + { + var card = new Card(TestBoardId, TestColumnId, "Valid"); + card.SetPosition(pos); + card.Position.Should().Be(pos); + }); + } + + [Property(MaxTest = MaxTests)] + public Property BlockUnblock_CyclePreservesState() + { + return Prop.ForAll( + Arb.From(Gen.Choose(1, 10)), + cycles => + { + var card = new Card(TestBoardId, TestColumnId, "Valid"); + for (int i = 0; i < cycles; i++) + { + card.Block("reason"); + card.IsBlocked.Should().BeTrue(); + card.BlockReason.Should().Be("reason"); + + card.Unblock(); + card.IsBlocked.Should().BeFalse(); + card.BlockReason.Should().BeNull(); + } + }); + } + + [Property(MaxTest = MaxTests)] + public Property Block_WithEmptyReason_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements("", " ", "\t", "\n")), + reason => + { + var card = new Card(TestBoardId, TestColumnId, "Valid"); + var act = () => card.Block(reason); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property MoveToColumn_UpdatesBothColumnAndPosition() + { + return Prop.ForAll( + Arb.From(Gen.Fresh(() => Guid.NewGuid())), + Arb.From(Gen.Choose(0, 1000)), + (newColId, newPos) => + { + var card = new Card(TestBoardId, TestColumnId, "Valid"); + card.MoveToColumn(newColId, newPos); + card.ColumnId.Should().Be(newColId); + card.Position.Should().Be(newPos); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ExplicitCardId_RejectsEmptyGuid() + { + var act = () => new Card(Guid.Empty, TestBoardId, TestColumnId, "Valid"); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + return true.ToProperty(); + } + + private static Arbitrary ValidCardTitleArb() + { + var gen = Gen.Choose(1, 200) + .SelectMany(len => + Gen.ArrayOf(len, Gen.Elements( + 'a', 'b', 'c', 'A', 'B', 'C', '1', '2', '3', ' ', '-', '_')) + .Select(chars => new string(chars))) + .Where(s => !string.IsNullOrWhiteSpace(s)); + return Arb.From(gen); + } +} diff --git a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/ColumnPropertyTests.cs b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/ColumnPropertyTests.cs new file mode 100644 index 000000000..3d87d8a9c --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/ColumnPropertyTests.cs @@ -0,0 +1,152 @@ +using FluentAssertions; +using FsCheck; +using FsCheck.Xunit; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Tests.PropertyBased; + +/// +/// Property-based tests for Column entity invariants. +/// Replay: set Replay = "seed,size" on any [Property] to reproduce a failing case. +/// +public class ColumnPropertyTests +{ + private const int MaxTests = 200; + + private static readonly Guid TestBoardId = Guid.NewGuid(); + + [Property(MaxTest = MaxTests)] + public Property ValidName_AlwaysCreatesColumn() + { + return Prop.ForAll( + ValidColumnNameArb(), + name => + { + var column = new Column(TestBoardId, name, 0); + column.Name.Should().Be(name); + column.BoardId.Should().Be(TestBoardId); + column.Position.Should().Be(0); + column.WipLimit.Should().BeNull(); + }); + } + + [Property(MaxTest = MaxTests)] + public Property EmptyOrWhitespaceName_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements("", " ", "\t", "\n")), + name => + { + var act = () => new Column(TestBoardId, name, 0); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NameExceeding50Chars_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(51, 200).Select(len => new string('x', len))), + longName => + { + var act = () => new Column(TestBoardId, longName, 0); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NameAtExactly50Chars_Succeeds() + { + var name50 = new string('a', 50); + var column = new Column(TestBoardId, name50, 0); + return (column.Name == name50).ToProperty(); + } + + [Property(MaxTest = MaxTests)] + public Property PositiveWipLimit_AlwaysAccepted() + { + return Prop.ForAll( + Arb.From(Gen.Choose(1, 10000)), + wipLimit => + { + var column = new Column(TestBoardId, "Valid", 0, wipLimit); + column.WipLimit.Should().Be(wipLimit); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ZeroOrNegativeWipLimit_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(-100, 0)), + wipLimit => + { + var act = () => new Column(TestBoardId, "Valid", 0, wipLimit); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NullWipLimit_AlwaysAccepted() + { + var column = new Column(TestBoardId, "Valid", 0, null); + column.WipLimit.Should().BeNull(); + return true.ToProperty(); + } + + [Property(MaxTest = MaxTests)] + public Property NegativePosition_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(-1000, -1)), + negPos => + { + var column = new Column(TestBoardId, "Valid", 0); + var act = () => column.SetPosition(negPos); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NonNegativePosition_AlwaysAccepted() + { + return Prop.ForAll( + Arb.From(Gen.Choose(0, 10000)), + pos => + { + var column = new Column(TestBoardId, "Valid", 0); + column.SetPosition(pos); + column.Position.Should().Be(pos); + }); + } + + [Property(MaxTest = MaxTests)] + public Property SetWipLimit_ThenClear_ResetsToNull() + { + return Prop.ForAll( + Arb.From(Gen.Choose(1, 100)), + wipLimit => + { + var column = new Column(TestBoardId, "Valid", 0, wipLimit); + column.WipLimit.Should().Be(wipLimit); + column.SetWipLimit(null); + column.WipLimit.Should().BeNull(); + }); + } + + private static Arbitrary ValidColumnNameArb() + { + var gen = Gen.Choose(1, 50) + .SelectMany(len => + Gen.ArrayOf(len, Gen.Elements( + 'a', 'b', 'c', 'A', 'B', 'C', '1', '2', '3', ' ', '-', '_')) + .Select(chars => new string(chars))) + .Where(s => !string.IsNullOrWhiteSpace(s)); + return Arb.From(gen); + } +} diff --git a/backend/tests/Taskdeck.Domain.Tests/PropertyBased/LabelPropertyTests.cs b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/LabelPropertyTests.cs new file mode 100644 index 000000000..744f8cf7b --- /dev/null +++ b/backend/tests/Taskdeck.Domain.Tests/PropertyBased/LabelPropertyTests.cs @@ -0,0 +1,161 @@ +using FluentAssertions; +using FsCheck; +using FsCheck.Xunit; +using Taskdeck.Domain.Entities; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Domain.Tests.PropertyBased; + +/// +/// Property-based tests for Label entity invariants. +/// Replay: set Replay = "seed,size" on any [Property] to reproduce a failing case. +/// +public class LabelPropertyTests +{ + private const int MaxTests = 200; + + private static readonly Guid TestBoardId = Guid.NewGuid(); + + [Property(MaxTest = MaxTests)] + public Property ValidHexColor_AlwaysAccepted() + { + return Prop.ForAll( + ValidHexColorArb(), + color => + { + var label = new Label(TestBoardId, "Valid", color); + label.ColorHex.Should().Be(color.ToUpperInvariant()); + }); + } + + [Property(MaxTest = MaxTests)] + public Property InvalidHexColor_AlwaysThrows() + { + return Prop.ForAll( + InvalidHexColorArb(), + color => + { + var act = () => new Label(TestBoardId, "Valid", color); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property ValidName_AlwaysCreatesLabel() + { + return Prop.ForAll( + ValidLabelNameArb(), + name => + { + var label = new Label(TestBoardId, name, "#FF0000"); + label.Name.Should().Be(name); + label.BoardId.Should().Be(TestBoardId); + }); + } + + [Property(MaxTest = MaxTests)] + public Property EmptyOrWhitespaceName_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Elements("", " ", "\t", "\n")), + name => + { + var act = () => new Label(TestBoardId, name, "#FF0000"); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NameExceeding30Chars_AlwaysThrows() + { + return Prop.ForAll( + Arb.From(Gen.Choose(31, 100).Select(len => new string('x', len))), + longName => + { + var act = () => new Label(TestBoardId, longName, "#FF0000"); + act.Should().Throw() + .Where(e => e.ErrorCode == ErrorCodes.ValidationError); + }); + } + + [Property(MaxTest = MaxTests)] + public Property NameAtExactly30Chars_Succeeds() + { + var name30 = new string('a', 30); + var label = new Label(TestBoardId, name30, "#FF0000"); + return (label.Name == name30).ToProperty(); + } + + [Property(MaxTest = MaxTests)] + public Property ColorIsAlwaysUppercased() + { + return Prop.ForAll( + ValidHexColorArb(), + color => + { + var label = new Label(TestBoardId, "Test", color); + label.ColorHex.Should().Be(label.ColorHex.ToUpperInvariant()); + }); + } + + [Property(MaxTest = MaxTests)] + public Property Update_PreservesUnchangedFields() + { + return Prop.ForAll( + ValidLabelNameArb(), + name => + { + var label = new Label(TestBoardId, "Original", "#FF0000"); + label.Update(name: name); + label.Name.Should().Be(name); + label.ColorHex.Should().Be("#FF0000"); // unchanged + }); + } + + /// + /// Generates valid hex colors in format #RRGGBB with mixed case. + /// + private static Arbitrary ValidHexColorArb() + { + var hexChars = "0123456789abcdefABCDEF".ToCharArray(); + var gen = Gen.ArrayOf(6, Gen.Elements(hexChars)) + .Select(chars => "#" + new string(chars)); + return Arb.From(gen); + } + + /// + /// Generates strings that are NOT valid hex colors. + /// + private static Arbitrary InvalidHexColorArb() + { + return Arb.From(Gen.OneOf( + // Missing hash + Gen.Constant("FF0000"), + // Too short + Gen.Constant("#FFF"), + // Too long + Gen.Constant("#FF00001"), + // Invalid chars + Gen.Constant("#GGGGGG"), + Gen.Constant("#ZZZZZZ"), + // Empty + Gen.Constant(""), + Gen.Constant(" "), + // Random non-hex strings + Gen.Elements("red", "blue", "rgb(0,0,0)", "#12345G", "##FF0000") + )); + } + + private static Arbitrary ValidLabelNameArb() + { + var gen = Gen.Choose(1, 30) + .SelectMany(len => + Gen.ArrayOf(len, Gen.Elements( + 'a', 'b', 'c', 'A', 'B', 'C', '1', '2', '3', ' ', '-', '_')) + .Select(chars => new string(chars))) + .Where(s => !string.IsNullOrWhiteSpace(s)); + return Arb.From(gen); + } +} diff --git a/backend/tests/Taskdeck.Domain.Tests/Taskdeck.Domain.Tests.csproj b/backend/tests/Taskdeck.Domain.Tests/Taskdeck.Domain.Tests.csproj index 1fc6271a1..67e629e5c 100644 --- a/backend/tests/Taskdeck.Domain.Tests/Taskdeck.Domain.Tests.csproj +++ b/backend/tests/Taskdeck.Domain.Tests/Taskdeck.Domain.Tests.csproj @@ -20,6 +20,8 @@ all + +