Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
2812baf
Migrate target framework from net6.0 to net10.0
deuveme Apr 22, 2026
c5da622
chore: scaffold net10 solution and domain value objects
deuveme Apr 22, 2026
2e96228
feat(domain): CnbDailyTxtParser — happy path
deuveme Apr 22, 2026
7508fab
feat(domain): CnbDailyTxtParser — error handling
deuveme Apr 22, 2026
10decd0
feat(application): IExchangeRateProvider port
deuveme Apr 22, 2026
895d0e1
feat(infra): CnbOptions, CnbOptionsValidator and CacheSnapshot
deuveme Apr 22, 2026
e9a2b94
feat(infra): ExchangeRateProvider — guard clauses and filtering
deuveme Apr 22, 2026
2c7f5da
feat(infra): ExchangeRateProvider — HTTP semantics, concurrency and d…
deuveme Apr 22, 2026
064df4f
feat(infra): ExchangeRateProvider — TTL, retry invariants and snapsho…
deuveme Apr 22, 2026
a48044b
feat(app): DI wiring, resilience pipeline and ValidateOnStart
deuveme Apr 22, 2026
00de18d
feat(infra): finalize sync GetExchangeRates behavior
deuveme Apr 22, 2026
34fb5eb
fix(config): make appsettings loading independent from cwd
deuveme Apr 22, 2026
e5394b9
refactor(domain): enforce currency and rate invariants
deuveme Apr 22, 2026
597e9a2
refactor(provider): add async API with cancellation support
deuveme Apr 22, 2026
cb40b6d
refactor(provider): remove dead guard and redundant normalization
deuveme Apr 22, 2026
9996ed2
feat(domain): support dot-decimal separator from CNB english endpoint
deuveme Apr 22, 2026
31aa573
test(provider): replace Thread.Sleep with explicit sync in concurrenc…
deuveme Apr 22, 2026
ee071cb
refactor(provider): replace async wrapper with native sync implementa…
deuveme Apr 22, 2026
f0ce223
fix: correct timeout exception type, dead code and stream ownership
deuveme Apr 22, 2026
401e9c2
fix: code quality, nullable annotations, observability and contract e…
deuveme Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
169 changes: 169 additions & 0 deletions jobs/Backend/Task.Tests/Domain/CnbDailyTxtParserTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
using ExchangeRateUpdater.Domain;
using Xunit;

namespace Task.Tests.Domain;

public sealed class CnbDailyTxtParserTests
{
[Fact]
public void Parse_valid_payload_returns_correct_rates()
{
string payload = """
22.04.2026 #78
Country|Currency|Amount|Code|Rate
Australia|dollar|1|AUD|14,123
United States|dollar|1|usd|22,640
""";

ExchangeRate[] rates = CnbDailyTxtParser.Parse(payload).ToArray();

Assert.Equal(2, rates.Length);
Assert.Equal("AUD", rates[0].SourceCurrency.Code);
Assert.Equal("CZK", rates[0].TargetCurrency.Code);
Assert.Equal(14.123m, rates[0].Value);
Assert.Equal("USD", rates[1].SourceCurrency.Code);
Assert.Equal(22.640m, rates[1].Value);
}

[Fact]
public void Parse_amount_greater_than_one_normalizes_value()
{
string payload = """
22.04.2026 #78
Country|Currency|Amount|Code|Rate
Japan|yen|100|JPY|15,320
""";

ExchangeRate rate = CnbDailyTxtParser.Parse(payload).Single();

Assert.Equal("JPY", rate.SourceCurrency.Code);
Assert.Equal(0.1532m, rate.Value);
}

[Fact]
public void Parse_skips_header_lines_correctly()
{
string payload = """
22.04.2026 #78
This line is metadata
Another metadata line
Country|Currency|Amount|Code|Rate
Sweden|krona|1|sek|2,078
""";

ExchangeRate rate = CnbDailyTxtParser.Parse(payload).Single();

Assert.Equal("SEK", rate.SourceCurrency.Code);
Assert.Equal("CZK", rate.TargetCurrency.Code);
Assert.Equal(2.078m, rate.Value);
}

[Fact]
public void Parse_accepts_dot_decimal_from_english_cnb_endpoint()
{
string payload = """
22.04.2026 #78
Country|Currency|Amount|Code|Rate
Australia|dollar|1|AUD|14.795
Japan|yen|100|JPY|15.320
""";

ExchangeRate[] rates = CnbDailyTxtParser.Parse(payload).ToArray();

Assert.Equal(2, rates.Length);
Assert.Equal("AUD", rates[0].SourceCurrency.Code);
Assert.Equal(14.795m, rates[0].Value);
Assert.Equal("JPY", rates[1].SourceCurrency.Code);
Assert.Equal(0.1532m, rates[1].Value);
}

[Fact]
public void Parse_malformed_line_throws_with_line_number()
{
string payload = """
22.04.2026 #78
Country|Currency|Amount|Code|Rate
Australia|dollar|1|AUD
""";

FormatException ex = Assert.Throws<FormatException>(() => CnbDailyTxtParser.Parse(payload).ToArray());

Assert.Contains("Malformed CNB line.", ex.Message);
Assert.Contains("Line 3", ex.Message);
}

[Fact]
public void Parse_invalid_decimal_throws()
{
string payload = """
22.04.2026 #78
Country|Currency|Amount|Code|Rate
Australia|dollar|oops|AUD|14,123
""";

FormatException ex = Assert.Throws<FormatException>(() => CnbDailyTxtParser.Parse(payload).ToArray());

Assert.Contains("Invalid amount in CNB line.", ex.Message);
Assert.Contains("Line 3", ex.Message);
}

[Fact]
public void Parse_missing_header_throws()
{
string payload = """
22.04.2026 #78
Metadata line
Australia|dollar|1|AUD|14,123
""";

FormatException ex = Assert.Throws<FormatException>(() => CnbDailyTxtParser.Parse(payload).ToArray());

Assert.Contains("Missing CNB header line.", ex.Message);
}

[Fact]
public void Parse_rejects_zero_or_negative_amount()
{
string payload = """
22.04.2026 #78
Country|Currency|Amount|Code|Rate
Australia|dollar|0|AUD|14,123
""";

FormatException ex = Assert.Throws<FormatException>(() => CnbDailyTxtParser.Parse(payload).ToArray());

Assert.Contains("Amount must be greater than zero.", ex.Message);
Assert.Contains("Line 3", ex.Message);
}

[Fact]
public void Parse_rejects_zero_or_negative_rate()
{
string payload = """
22.04.2026 #78
Country|Currency|Amount|Code|Rate
Australia|dollar|1|AUD|0
""";

FormatException ex = Assert.Throws<FormatException>(() => CnbDailyTxtParser.Parse(payload).ToArray());

Assert.Contains("Rate must be greater than zero.", ex.Message);
Assert.Contains("Line 3", ex.Message);
}

[Fact]
public void Parse_malformed_line_longer_than_100_chars_is_truncated_in_error()
{
string longCountryName = new('A', 101);
string payload = $"""
22.04.2026 #78
Country|Currency|Amount|Code|Rate
{longCountryName}|dollar|1|AUD
""";

FormatException ex = Assert.Throws<FormatException>(() => CnbDailyTxtParser.Parse(payload).ToArray());

Assert.Contains("...", ex.Message);
Assert.DoesNotContain(longCountryName, ex.Message);
}
}
41 changes: 41 additions & 0 deletions jobs/Backend/Task.Tests/Domain/DomainInvariantsTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
using ExchangeRateUpdater.Domain;
using Xunit;

namespace Task.Tests.Domain;

public sealed class DomainInvariantsTests
{
[Fact]
public void Currency_normalizes_code()
{
Currency currency = new(" usd ");

Assert.Equal("USD", currency.Code);
}

[Fact]
public void Currency_empty_or_whitespace_throws()
{
Assert.Throws<ArgumentException>(() => new Currency(""));
Assert.Throws<ArgumentException>(() => new Currency(" "));
}

[Fact]
public void ExchangeRate_requires_non_null_currencies()
{
Currency czk = new("CZK");

Assert.Throws<ArgumentNullException>(() => new ExchangeRate(null!, czk, 1m));
Assert.Throws<ArgumentNullException>(() => new ExchangeRate(new Currency("USD"), null!, 1m));
}

[Fact]
public void ExchangeRate_requires_positive_value()
{
Currency usd = new("USD");
Currency czk = new("CZK");

Assert.Throws<ArgumentOutOfRangeException>(() => new ExchangeRate(usd, czk, 0m));
Assert.Throws<ArgumentOutOfRangeException>(() => new ExchangeRate(usd, czk, -1m));
}
}
87 changes: 87 additions & 0 deletions jobs/Backend/Task.Tests/Infrastructure/CnbOptionsValidatorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
using ExchangeRateUpdater.Infrastructure;
using Xunit;

namespace Task.Tests.Infrastructure;

public sealed class CnbOptionsValidatorTests
{
private readonly CnbOptionsValidator _validator = new();

[Fact]
public void Validate_valid_http_url_returns_success()
{
CnbOptions options = new()
{
Url = "http://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/denni_kurz.txt"
};

var result = _validator.Validate(name: null, options);

Assert.True(result.Succeeded);
}

[Fact]
public void Validate_valid_https_url_returns_success()
{
CnbOptions options = new()
{
Url = "https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/denni_kurz.txt"
};

var result = _validator.Validate(name: null, options);

Assert.True(result.Succeeded);
}

[Fact]
public void Validate_relative_url_returns_failure()
{
CnbOptions options = new()
{
Url = "/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/denni_kurz.txt"
};

var result = _validator.Validate(name: null, options);

Assert.False(result.Succeeded);
}

[Fact]
public void Validate_non_http_scheme_returns_failure()
{
CnbOptions options = new()
{
Url = "ftp://www.cnb.cz/path"
};

var result = _validator.Validate(name: null, options);

Assert.False(result.Succeeded);
}

[Fact]
public void Validate_empty_url_returns_failure()
{
CnbOptions options = new()
{
Url = ""
};

var result = _validator.Validate(name: null, options);

Assert.False(result.Succeeded);
}

[Fact]
public void Validate_malformed_url_returns_failure()
{
CnbOptions options = new()
{
Url = "http://"
};

var result = _validator.Validate(name: null, options);

Assert.False(result.Succeeded);
}
}
Loading
Loading