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
+
+