Skip to content

Commit dec1398

Browse files
authored
Merge pull request #601 from Chris0Jeky/test/89-property-based-fuzz-testing
Add property-based and fuzz testing pilot for domain/API contracts
2 parents 25f6e06 + 4a02be1 commit dec1398

File tree

10 files changed

+1660
-0
lines changed

10 files changed

+1660
-0
lines changed
Lines changed: 295 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,295 @@
1+
using System.Text.Json;
2+
using FluentAssertions;
3+
using FsCheck;
4+
using FsCheck.Xunit;
5+
using Taskdeck.Application.DTOs;
6+
7+
namespace Taskdeck.Application.Tests.Fuzz;
8+
9+
/// <summary>
10+
/// Fuzz-style tests for export/import DTO serialization contracts.
11+
/// Verifies JSON roundtrip fidelity and that deserialization of arbitrary payloads
12+
/// never throws unhandled exceptions.
13+
/// Replay: set Replay = "seed,size" on any [Property] to reproduce a failing case.
14+
/// </summary>
15+
public class ExportImportContractFuzzTests
16+
{
17+
private const int MaxTests = 200;
18+
19+
private static readonly JsonSerializerOptions CamelCaseOptions = new()
20+
{
21+
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
22+
PropertyNameCaseInsensitive = true
23+
};
24+
25+
[Property(MaxTest = MaxTests)]
26+
public Property ImportBoardDto_Roundtrip_PreservesData()
27+
{
28+
return Prop.ForAll(
29+
ValidImportBoardDtoArb(),
30+
dto =>
31+
{
32+
var json = JsonSerializer.Serialize(dto, CamelCaseOptions);
33+
var deserialized = JsonSerializer.Deserialize<ImportBoardDto>(json, CamelCaseOptions);
34+
35+
deserialized.Should().NotBeNull();
36+
deserialized!.Name.Should().Be(dto.Name);
37+
deserialized.Description.Should().Be(dto.Description);
38+
deserialized.Columns.Count().Should().Be(dto.Columns.Count());
39+
deserialized.Cards.Count().Should().Be(dto.Cards.Count());
40+
deserialized.Labels.Count().Should().Be(dto.Labels.Count());
41+
});
42+
}
43+
44+
[Property(MaxTest = MaxTests)]
45+
public Property ImportBoardDto_Deserialize_NeverThrows_OnArbitraryJson()
46+
{
47+
return Prop.ForAll(
48+
Arb.From<string>(),
49+
json =>
50+
{
51+
// Deserialization of arbitrary strings should either succeed or throw JsonException,
52+
// never an unhandled exception type
53+
try
54+
{
55+
JsonSerializer.Deserialize<ImportBoardDto>(json ?? "null", CamelCaseOptions);
56+
}
57+
catch (JsonException)
58+
{
59+
// Expected for malformed JSON
60+
}
61+
catch (Exception ex)
62+
{
63+
// Any other exception type is a test failure
64+
ex.Should().BeNull($"Unexpected exception type {ex.GetType()}: {ex.Message}");
65+
}
66+
});
67+
}
68+
69+
[Property(MaxTest = MaxTests)]
70+
public Property ImportColumnDto_Roundtrip_PreservesData()
71+
{
72+
return Prop.ForAll(
73+
ValidImportColumnDtoArb(),
74+
dto =>
75+
{
76+
var json = JsonSerializer.Serialize(dto, CamelCaseOptions);
77+
var deserialized = JsonSerializer.Deserialize<ImportColumnDto>(json, CamelCaseOptions);
78+
79+
deserialized.Should().NotBeNull();
80+
deserialized!.Name.Should().Be(dto.Name);
81+
deserialized.Position.Should().Be(dto.Position);
82+
deserialized.WipLimit.Should().Be(dto.WipLimit);
83+
});
84+
}
85+
86+
[Property(MaxTest = MaxTests)]
87+
public Property ImportCardDto_Roundtrip_PreservesData()
88+
{
89+
return Prop.ForAll(
90+
ValidImportCardDtoArb(),
91+
dto =>
92+
{
93+
var json = JsonSerializer.Serialize(dto, CamelCaseOptions);
94+
var deserialized = JsonSerializer.Deserialize<ImportCardDto>(json, CamelCaseOptions);
95+
96+
deserialized.Should().NotBeNull();
97+
deserialized!.Title.Should().Be(dto.Title);
98+
deserialized.Description.Should().Be(dto.Description);
99+
deserialized.ColumnName.Should().Be(dto.ColumnName);
100+
deserialized.Position.Should().Be(dto.Position);
101+
});
102+
}
103+
104+
[Property(MaxTest = MaxTests)]
105+
public Property ImportLabelDto_Roundtrip_PreservesData()
106+
{
107+
return Prop.ForAll(
108+
ValidImportLabelDtoArb(),
109+
dto =>
110+
{
111+
var json = JsonSerializer.Serialize(dto, CamelCaseOptions);
112+
var deserialized = JsonSerializer.Deserialize<ImportLabelDto>(json, CamelCaseOptions);
113+
114+
deserialized.Should().NotBeNull();
115+
deserialized!.Name.Should().Be(dto.Name);
116+
deserialized.Color.Should().Be(dto.Color);
117+
});
118+
}
119+
120+
[Property(MaxTest = MaxTests)]
121+
public Property ImportBoardDto_ExtraFields_IgnoredGracefully()
122+
{
123+
// Verify that extra/unknown fields in JSON don't break deserialization
124+
return Prop.ForAll(
125+
Arb.From(Gen.OneOf(
126+
Gen.Constant("{\"name\":\"Test\",\"unknownField\":42,\"columns\":[],\"cards\":[],\"labels\":[]}"),
127+
Gen.Constant("{\"name\":\"Test\",\"description\":\"Desc\",\"extra\":{\"nested\":true},\"columns\":[],\"cards\":[],\"labels\":[]}"),
128+
Gen.Constant("{\"name\":\"Test\",\"columns\":[{\"name\":\"Col\",\"position\":0,\"extra\":true}],\"cards\":[],\"labels\":[]}")
129+
)),
130+
json =>
131+
{
132+
var act = () => JsonSerializer.Deserialize<ImportBoardDto>(json, CamelCaseOptions);
133+
act.Should().NotThrow("extra fields should be silently ignored");
134+
});
135+
}
136+
137+
[Property(MaxTest = MaxTests)]
138+
public Property ImportBoardDto_MissingFields_HandledGracefully()
139+
{
140+
return Prop.ForAll(
141+
Arb.From(Gen.OneOf(
142+
Gen.Constant("{}"),
143+
Gen.Constant("{\"name\":null}"),
144+
Gen.Constant("{\"name\":\"Test\"}"),
145+
Gen.Constant("{\"columns\":[]}"),
146+
Gen.Constant("{\"name\":\"\",\"columns\":null,\"cards\":null,\"labels\":null}")
147+
)),
148+
json =>
149+
{
150+
// Should deserialize without throwing (fields may be null/default)
151+
var act = () => JsonSerializer.Deserialize<ImportBoardDto>(json, CamelCaseOptions);
152+
act.Should().NotThrow("missing fields should result in defaults, not exceptions");
153+
});
154+
}
155+
156+
[Property(MaxTest = MaxTests)]
157+
public Property StarterPackManifestDto_Roundtrip_PreservesData()
158+
{
159+
return Prop.ForAll(
160+
ValidStarterPackDtoArb(),
161+
dto =>
162+
{
163+
var json = JsonSerializer.Serialize(dto, CamelCaseOptions);
164+
var deserialized = JsonSerializer.Deserialize<StarterPackManifestDto>(json, CamelCaseOptions);
165+
166+
deserialized.Should().NotBeNull();
167+
deserialized!.SchemaVersion.Should().Be(dto.SchemaVersion);
168+
deserialized.PackId.Should().Be(dto.PackId);
169+
deserialized.DisplayName.Should().Be(dto.DisplayName);
170+
deserialized.Labels.Count.Should().Be(dto.Labels.Count);
171+
deserialized.Columns.Count.Should().Be(dto.Columns.Count);
172+
});
173+
}
174+
175+
private static Arbitrary<ImportBoardDto> ValidImportBoardDtoArb()
176+
{
177+
var gen = Gen.Choose(0, 5).SelectMany(colCount =>
178+
Gen.Choose(0, 5).SelectMany(cardCount =>
179+
Gen.Choose(0, 3).Select(labelCount =>
180+
{
181+
var columns = Enumerable.Range(0, colCount)
182+
.Select(i => new ImportColumnDto($"Column{i}", i, i > 0 ? i * 3 : (int?)null))
183+
.ToList();
184+
185+
var columnNames = columns.Select(c => c.Name).ToArray();
186+
187+
var cards = Enumerable.Range(0, cardCount)
188+
.Select(i => new ImportCardDto(
189+
$"Card{i}",
190+
$"Description for card {i}",
191+
columnNames.Length > 0 ? columnNames[i % columnNames.Length] : "Default",
192+
i,
193+
i % 2 == 0 ? DateTimeOffset.UtcNow.AddDays(i) : null,
194+
null))
195+
.ToList();
196+
197+
var labels = Enumerable.Range(0, labelCount)
198+
.Select(i =>
199+
{
200+
var hex = ((i + 1) * 55 % 256).ToString("X2");
201+
return new ImportLabelDto($"Label{i}", $"#{hex}{hex}{hex}");
202+
})
203+
.ToList();
204+
205+
return new ImportBoardDto(
206+
$"Board-{colCount}-{cardCount}",
207+
"Test board description",
208+
columns,
209+
cards,
210+
labels);
211+
})));
212+
return Arb.From(gen);
213+
}
214+
215+
private static Arbitrary<ImportColumnDto> ValidImportColumnDtoArb()
216+
{
217+
var gen = Gen.Choose(0, 100).SelectMany(pos =>
218+
Gen.OneOf(
219+
Gen.Constant((int?)null),
220+
Gen.Choose(1, 50).Select(v => (int?)v)
221+
).Select(wipLimit =>
222+
new ImportColumnDto($"Column-{pos}", pos, wipLimit)));
223+
return Arb.From(gen);
224+
}
225+
226+
private static Arbitrary<ImportCardDto> ValidImportCardDtoArb()
227+
{
228+
var gen = Gen.Choose(0, 100).Select(pos =>
229+
new ImportCardDto(
230+
$"Card-{pos}",
231+
$"Description for card {pos}",
232+
"Default",
233+
pos,
234+
pos % 2 == 0 ? DateTimeOffset.UtcNow.AddDays(pos) : null,
235+
pos % 3 == 0 ? new[] { "Label1" } : null));
236+
return Arb.From(gen);
237+
}
238+
239+
private static Arbitrary<ImportLabelDto> ValidImportLabelDtoArb()
240+
{
241+
var gen = Gen.Choose(0, 255).Select(i =>
242+
{
243+
var hex = i.ToString("X2");
244+
return new ImportLabelDto($"Label-{i}", $"#{hex}{hex}{hex}");
245+
});
246+
return Arb.From(gen);
247+
}
248+
249+
private static Arbitrary<StarterPackManifestDto> ValidStarterPackDtoArb()
250+
{
251+
var gen = Gen.Choose(1, 3).SelectMany(labelCount =>
252+
Gen.Choose(1, 4).Select(colCount =>
253+
{
254+
var labels = Enumerable.Range(0, labelCount)
255+
.Select(i =>
256+
{
257+
var hex = ((i + 1) * 37 % 256).ToString("X2");
258+
return new StarterPackLabelDto
259+
{
260+
Name = $"Label{i}",
261+
Color = $"#{hex}{hex}{hex}"
262+
};
263+
})
264+
.ToList();
265+
266+
var columns = Enumerable.Range(0, colCount)
267+
.Select(i => new StarterPackColumnDto
268+
{
269+
Name = $"Column{i}",
270+
Position = i,
271+
WipLimit = i > 0 ? i * 5 : null
272+
})
273+
.ToList();
274+
275+
return new StarterPackManifestDto
276+
{
277+
SchemaVersion = "1.0",
278+
PackId = "test-pack",
279+
DisplayName = "Test Pack",
280+
Description = "Fuzz test pack",
281+
Compatibility = new StarterPackCompatibilityDto
282+
{
283+
MinTaskdeckVersion = "0.1.0",
284+
RequiredFeatures = new List<string>()
285+
},
286+
Tags = new List<string> { "test" },
287+
Labels = labels,
288+
Columns = columns,
289+
Templates = new List<StarterPackCardTemplateDto>(),
290+
SeedCards = new List<StarterPackSeedCardDto>()
291+
};
292+
}));
293+
return Arb.From(gen);
294+
}
295+
}

0 commit comments

Comments
 (0)