Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
1 change: 1 addition & 0 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ jobs:
sed -i 's/"__S3_BUCKET_NAME",/"${{ vars.S3_BUCKET_NAME }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}}
sed -i 's/"__TELEGRAM_BOT_KEY",/"${{ secrets.TELEGRAM_BOT_KEY }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}}
sed -i 's/"__SENDGRID_API_KEY",/"${{ secrets.SENDGRID_API_KEY }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}}
sed -i 's/"__OPEN_AI_SECRET",/"${{ secrets.OPEN_AI_SECRET }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}}
Copy link
Contributor

@coderabbitai coderabbitai bot May 3, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue

Missing JWT secret injection for production settings.

You’ve added JWT generation in this PR, but there’s no replacement step for a __JWT_SECRET placeholder in appsettings.Production.json. Without it, the app will continue to see the literal placeholder and fail at runtime. Add something like:

sed -i 's/"__JWT_SECRET",/"${{ secrets.JWT_SECRET }}",/g' ${{env.PRODUCTION_SETTINGS_FILE}}

right after the other sed commands.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

JWT is generated with secret that in settings now. That's ok

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@maximgorbatyuk I understand now. Since you're using the OpenAI API secret as the JWT secret, and that replacement is already handled in line 28, no additional secret injection is needed. Thanks for the clarification!

cd src
docker login -u ${{ env.DO_TOKEN }} -p ${{ env.DO_TOKEN }} registry.digitalocean.com/techinterview
docker build -f Dockerfile -t ${{ secrets.CR }}/backend:${{ github.sha }} .
Expand Down
3 changes: 2 additions & 1 deletion src/.env.example
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
SendGridApiKey=''
IdentityServer_Audience=''
Telegram__BotToken=''
Telegram__BotToken=''
OpenAiApi__Secret=''
15 changes: 15 additions & 0 deletions src/Domain/Entities/StatData/SalaryBaseData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System;
using Domain.Entities.Enums;

namespace Domain.Entities.StatData;

public record SalaryBaseData
{
public long? ProfessionId { get; init; }

public DeveloperGrade Grade { get; init; }

public double Value { get; init; }

public DateTimeOffset CreatedAt { get; init; }
}
10 changes: 0 additions & 10 deletions src/Domain/Entities/StatData/SalaryGraveValue.cs

This file was deleted.

13 changes: 11 additions & 2 deletions src/Domain/Entities/StatData/StatDataCacheItemSalaryData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,18 @@ public StatDataCacheItemSalaryData()
}

public StatDataCacheItemSalaryData(
List<SalaryGraveValue> values,
List<SalaryBaseData> values,
int totalCount)
{
var salaryValues = values.Select(x => x.Value).ToList();
var salaryValues = values
.Select(x => x.Value)
.ToList();

MedianLocalSalary = salaryValues.Median();
AverageLocalSalary = salaryValues.Count > 0 ? salaryValues.Average() : 0;
TotalSalaryCount = totalCount;
MinLocalSalary = salaryValues.Count > 0 ? salaryValues.Min() : null;
MaxLocalSalary = salaryValues.Count > 0 ? salaryValues.Max() : null;

MedianLocalSalaryByGrade = new Dictionary<GradeGroup, double>();

Expand All @@ -51,6 +56,10 @@ public StatDataCacheItemSalaryData(

public double AverageLocalSalary { get; init; }

public double? MinLocalSalary { get; init; }

public double? MaxLocalSalary { get; init; }

public Dictionary<GradeGroup, double> MedianLocalSalaryByGrade { get; init; } = new ();

public int TotalSalaryCount { get; init; }
Expand Down
4 changes: 2 additions & 2 deletions src/Domain/ValueObjects/ISalariesChartQueryParams.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ public interface ISalariesChartQueryParams
{
public DeveloperGrade? Grade { get; }

public List<long> ProfessionsToInclude { get; }
public List<long> SelectedProfessionIds { get; }

public List<long> Skills { get; }

Expand All @@ -22,7 +22,7 @@ public interface ISalariesChartQueryParams
public int? YearTo { get; }

public bool HasAnyFilter =>
Grade.HasValue || ProfessionsToInclude.Count > 0 || Cities.Count > 0;
Grade.HasValue || SelectedProfessionIds.Count > 0 || Cities.Count > 0;

string GetKeyPostfix();
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ namespace Infrastructure.Currencies.Contracts
{
public interface ICurrencyService
{
Task<CurrencyContent> GetCurrencyAsync(
Task<CurrencyContent> GetCurrencyOrNullAsync(
Currency currency,
CancellationToken cancellationToken);

Expand Down
2 changes: 1 addition & 1 deletion src/Infrastructure/Currencies/CurrencyService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public CurrencyService(
_logger = logger;
}

public async Task<CurrencyContent> GetCurrencyAsync(
public async Task<CurrencyContent> GetCurrencyOrNullAsync(
Currency currency,
CancellationToken cancellationToken)
{
Expand Down
1 change: 1 addition & 0 deletions src/Infrastructure/Infrastructure.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
<PackageReference Include="QuestPDF" Version="2025.1.2" />
<PackageReference Include="QuestPDF.Markdown" Version="1.31.0" />
<PackageReference Include="SendGrid" Version="9.29.3" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.9.0" />
<PackageReference Include="System.Net.Http" Version="4.3.4" />
<PackageReference Include="System.Text.Encodings.Web" Version="9.0.2" />
<PackageReference Include="System.Text.RegularExpressions" Version="4.3.1" />
Expand Down
44 changes: 44 additions & 0 deletions src/Infrastructure/Jwt/TechinterviewJwtTokenGenerator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
using System.IdentityModel.Tokens.Jwt;
using System.Security.Claims;
using System.Text;
using Microsoft.IdentityModel.Tokens;

namespace Infrastructure.Jwt;

public record TechinterviewJwtTokenGenerator
{
private readonly string _secretKey;

private string _result;

public TechinterviewJwtTokenGenerator(
string secretKey)
{
_secretKey = secretKey;
}

public override string ToString()
{
return _result ??= GenerateInternal();
}

private string GenerateInternal()
{
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);

var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Name, "TechInterview.space Bot"),
new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
};

var token = new JwtSecurityToken(
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials);

return new JwtSecurityTokenHandler().WriteToken(token);
}
}
Comment on lines +8 to +44
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

JWT token generator uses unconventional design patterns

While the token generation logic works correctly, there are several design concerns:

  1. Using a record with mutable state (_result) is unconventional - records are typically used for immutable data
  2. The token is generated via ToString() which is not intuitive for JWT token generation
  3. The generated token has a fixed 1-hour expiration but is cached indefinitely, potentially causing authentication failures if used after expiration
  4. No validation is performed on the secret key to ensure it meets security requirements

Consider these improvements:

-public record TechinterviewJwtTokenGenerator
+public class TechinterviewJwtTokenGenerator
 {
     private readonly string _secretKey;
-
-    private string _result;
+    private string _cachedToken;
+    private DateTime _tokenExpiration;

     public TechinterviewJwtTokenGenerator(
         string secretKey)
     {
+        if (string.IsNullOrWhiteSpace(secretKey) || secretKey.Length < 32)
+        {
+            throw new ArgumentException("Secret key must be at least 32 characters long for security", nameof(secretKey));
+        }
         _secretKey = secretKey;
     }

-    public override string ToString()
+    public string GenerateToken()
     {
-        return _result ??= GenerateInternal();
+        // Regenerate if token doesn't exist or is close to expiration (10 min buffer)
+        if (_cachedToken == null || DateTime.UtcNow.AddMinutes(10) > _tokenExpiration)
+        {
+            _cachedToken = GenerateInternal();
+            _tokenExpiration = DateTime.UtcNow.AddHours(1);
+        }
+        return _cachedToken;
     }

     private string GenerateInternal()
     {
         var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secretKey));
         var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);

         var claims = new List<Claim>
         {
             new Claim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()),
             new Claim(JwtRegisteredClaimNames.Name, "TechInterview.space Bot"),
             new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
         };

         var token = new JwtSecurityToken(
             claims: claims,
             expires: DateTime.UtcNow.AddHours(1),
             signingCredentials: credentials);

         return new JwtSecurityTokenHandler().WriteToken(token);
     }
+
+    // Keep ToString override for backward compatibility
+    public override string ToString()
+    {
+        return GenerateToken();
+    }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
public record TechinterviewJwtTokenGenerator
{
private readonly string _secretKey;
private string _result;
public TechinterviewJwtTokenGenerator(
string secretKey)
{
_secretKey = secretKey;
}
public override string ToString()
{
return _result ??= GenerateInternal();
}
private string GenerateInternal()
{
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Name, "TechInterview.space Bot"),
new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
};
var token = new JwtSecurityToken(
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
}
public class TechinterviewJwtTokenGenerator
{
private readonly string _secretKey;
private string _cachedToken;
private DateTime _tokenExpiration;
public TechinterviewJwtTokenGenerator(
string secretKey)
{
if (string.IsNullOrWhiteSpace(secretKey) || secretKey.Length < 32)
{
throw new ArgumentException("Secret key must be at least 32 characters long for security", nameof(secretKey));
}
_secretKey = secretKey;
}
public string GenerateToken()
{
// Regenerate if token doesn't exist or is close to expiration (10 min buffer)
if (_cachedToken == null || DateTime.UtcNow.AddMinutes(10) > _tokenExpiration)
{
_cachedToken = GenerateInternal();
_tokenExpiration = DateTime.UtcNow.AddHours(1);
}
return _cachedToken;
}
private string GenerateInternal()
{
var securityKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(_secretKey));
var credentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
var claims = new List<Claim>
{
new Claim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString()),
new Claim(JwtRegisteredClaimNames.Name, "TechInterview.space Bot"),
new Claim(JwtRegisteredClaimNames.Iat, DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(), ClaimValueTypes.Integer64)
};
var token = new JwtSecurityToken(
claims: claims,
expires: DateTime.UtcNow.AddHours(1),
signingCredentials: credentials);
return new JwtSecurityTokenHandler().WriteToken(token);
}
// Keep ToString override for backward compatibility
public override string ToString()
{
return GenerateToken();
}
}

4 changes: 2 additions & 2 deletions src/Infrastructure/Salaries/ChartPageLink.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,9 +45,9 @@ public override string ToString()
if (_requestOrNull != null || _additionalQueryParams.Count > 0)
{
string queryParams = null;
if (_requestOrNull?.ProfessionsToInclude.Count > 0)
if (_requestOrNull?.SelectedProfessionIds.Count > 0)
{
queryParams += $"?profsInclude={string.Join(",", _requestOrNull.ProfessionsToInclude)}";
queryParams += $"?profsInclude={string.Join(",", _requestOrNull.SelectedProfessionIds)}";
}

if (_requestOrNull?.Grade != null)
Expand Down
15 changes: 8 additions & 7 deletions src/Infrastructure/Salaries/SalariesForChartQuery.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,12 @@ namespace Infrastructure.Salaries;

public record SalariesForChartQuery
{
public const int MonthsToShow = 18;

public DateQuarter CurrentQuarter { get; }

public DeveloperGrade? Grade { get; }

public List<long> ProfessionsToInclude { get; }

public List<long> Skills { get; }

public List<KazakhstanCity> Cities { get; }
Expand All @@ -32,6 +32,7 @@ public record SalariesForChartQuery

public int? YearTo { get; }

private readonly List<long> _selectedProfessionIds;
private readonly DatabaseContext _context;

public SalariesForChartQuery(
Expand All @@ -48,7 +49,7 @@ public SalariesForChartQuery(
{
_context = context;
Grade = grade;
ProfessionsToInclude = professionsToInclude ?? new List<long>();
_selectedProfessionIds = professionsToInclude ?? new List<long>();
Skills = skills ?? new List<long>();

Cities = cities ?? new List<KazakhstanCity>();
Expand All @@ -69,7 +70,7 @@ public SalariesForChartQuery(
: this(
context,
request,
now.AddMonths(-18),
now.AddMonths(-MonthsToShow),
now)
{
}
Expand All @@ -82,7 +83,7 @@ public SalariesForChartQuery(
: this(
context,
request.Grade,
request.ProfessionsToInclude,
request.SelectedProfessionIds,
request.Skills,
request.Cities,
from,
Expand Down Expand Up @@ -179,8 +180,8 @@ private IQueryable<UserSalary> BuildQuery(
Skills.Count > 0,
x => x.SkillId != null && Skills.Contains(x.SkillId.Value))
.When(
ProfessionsToInclude.Count > 0,
x => x.ProfessionId != null && ProfessionsToInclude.Contains(x.ProfessionId.Value));
_selectedProfessionIds.Count > 0,
x => x.ProfessionId != null && _selectedProfessionIds.Contains(x.ProfessionId.Value));

return query;
}
Expand Down
9 changes: 9 additions & 0 deletions src/Infrastructure/Services/OpenAi/IOpenAiService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
namespace Infrastructure.Services.OpenAi;

public interface IOpenAiService
{
string GetBearer();

Task<string> GetAnalysisAsync(
CancellationToken cancellationToken = default);
}
63 changes: 63 additions & 0 deletions src/Infrastructure/Services/OpenAi/OpenAiService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
using System.Net.Http.Headers;
using Infrastructure.Jwt;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;

namespace Infrastructure.Services.OpenAi;

public class OpenAiService : IOpenAiService
{
private readonly IHttpClientFactory _httpClientFactory;
private readonly IConfiguration _configuration;
private readonly ILogger<OpenAiService> _logger;

public OpenAiService(
IHttpClientFactory httpClientFactory,
IConfiguration configuration,
ILogger<OpenAiService> logger)
{
_httpClientFactory = httpClientFactory;
_configuration = configuration;
_logger = logger;
}

public string GetBearer()
{
var secret = _configuration["OpenAiApi:Secret"];
return new TechinterviewJwtTokenGenerator(secret).ToString();
}
Comment on lines +26 to +28
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve secret handling with validation

The secret is read from configuration without any validation. If the secret is missing or empty, this could lead to authentication failures.

public string GetBearer()
{
    var secret = _configuration["OpenAiApi:Secret"];
+   if (string.IsNullOrEmpty(secret))
+   {
+       throw new InvalidOperationException("OpenAI API secret is not set");
+   }
    return new TechinterviewJwtTokenGenerator(secret).ToString();
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
var secret = _configuration["OpenAiApi:Secret"];
return new TechinterviewJwtTokenGenerator(secret).ToString();
}
public string GetBearer()
{
var secret = _configuration["OpenAiApi:Secret"];
if (string.IsNullOrEmpty(secret))
{
throw new InvalidOperationException("OpenAI API secret is not set");
}
return new TechinterviewJwtTokenGenerator(secret).ToString();
}


public async Task<string> GetAnalysisAsync(
CancellationToken cancellationToken = default)
{
var apiUrl = _configuration["OpenAiApi:Url"];
if (string.IsNullOrEmpty(apiUrl))
{
throw new InvalidOperationException("OpenAI API url is not set");
}

var responseContent = string.Empty;
try
{
using var client = _httpClientFactory.CreateClient();

client.BaseAddress = new Uri(apiUrl);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", GetBearer());

responseContent = await client.GetStringAsync(string.Empty, cancellationToken);
return responseContent;
}
catch (Exception e)
{
_logger.LogError(
e,
"Error while getting OpenAI response from {Url}. Message {Message}. Response {Response}",
apiUrl,
e.Message,
responseContent);

return string.Empty;
}
Comment on lines +48 to +61
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion

Improve error handling in API responses

Currently, the method returns an empty string on error. This makes it difficult for callers to distinguish between an actual empty response and an error condition.

Consider returning a result object that includes success/failure status and error information, or throwing a specific exception type that can be caught and handled by callers.

-public async Task<string> GetAnalysisAsync(
+public async Task<(bool Success, string Content, string ErrorMessage)> GetAnalysisAsync(
    CancellationToken cancellationToken = default)
{
    var apiUrl = _configuration["OpenAiApi:Url"];
    if (string.IsNullOrEmpty(apiUrl))
    {
        throw new InvalidOperationException("OpenAI API url is not set");
    }

    var responseContent = string.Empty;
    try
    {
        using var client = _httpClientFactory.CreateClient();

        client.BaseAddress = new Uri(apiUrl);
        client.DefaultRequestHeaders.Authorization =
            new AuthenticationHeaderValue("Bearer", GetBearer());

        responseContent = await client.GetStringAsync(string.Empty, cancellationToken);
-       return responseContent;
+       return (true, responseContent, string.Empty);
    }
    catch (Exception e)
    {
        _logger.LogError(
            e,
            "Error while getting OpenAI response from {Url}. Message {Message}. Response {Response}",
            apiUrl,
            e.Message,
            responseContent);

-       return string.Empty;
+       return (false, string.Empty, e.Message);
    }
}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
responseContent = await client.GetStringAsync(string.Empty, cancellationToken);
return responseContent;
}
catch (Exception e)
{
_logger.LogError(
e,
"Error while getting OpenAI response from {Url}. Message {Message}. Response {Response}",
apiUrl,
e.Message,
responseContent);
return string.Empty;
}
public async Task<(bool Success, string Content, string ErrorMessage)> GetAnalysisAsync(
CancellationToken cancellationToken = default)
{
var apiUrl = _configuration["OpenAiApi:Url"];
if (string.IsNullOrEmpty(apiUrl))
{
throw new InvalidOperationException("OpenAI API url is not set");
}
var responseContent = string.Empty;
try
{
using var client = _httpClientFactory.CreateClient();
client.BaseAddress = new Uri(apiUrl);
client.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", GetBearer());
responseContent = await client.GetStringAsync(string.Empty, cancellationToken);
return (true, responseContent, string.Empty);
}
catch (Exception e)
{
_logger.LogError(
e,
"Error while getting OpenAI response from {Url}. Message {Message}. Response {Response}",
apiUrl,
e.Message,
responseContent);
return (false, string.Empty, e.Message);
}
}

}
}
17 changes: 17 additions & 0 deletions src/InfrastructureTests/Jwt/TechinterviewJwtTokenGeneratorTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
using Infrastructure.Jwt;
using Xunit;

namespace InfrastructureTests.Jwt;

public class TechinterviewJwtTokenGeneratorTests
{
[Fact]
public void ToString_ShouldReturnToken()
{
const string secretKey = "hey-girl-its-super-secret-key-for-jwt";
var generator = new TechinterviewJwtTokenGenerator(secretKey);

var result = generator.ToString();
Assert.Contains("eyJhbGciOiJodHRwOi8vd3d3LnczLm9yZy8y", result);
}
}
2 changes: 1 addition & 1 deletion src/TestUtils/Mocks/CurrenciesServiceFake.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public CurrenciesServiceFake(
_currencies = currencies;
}

public Task<CurrencyContent> GetCurrencyAsync(
public Task<CurrencyContent> GetCurrencyOrNullAsync(
Currency currency,
CancellationToken cancellationToken)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ public void Ctor_TestersProfessions_Ok()

var target = new QaAndTestersTelegramBotUserCommandParameters(professions);

Assert.Equal(3, target.ProfessionsToInclude.Count);
Assert.Equal(professions[0].Id, target.ProfessionsToInclude[0]);
Assert.Equal(professions[1].Id, target.ProfessionsToInclude[1]);
Assert.Equal(professions[2].Id, target.ProfessionsToInclude[2]);
Assert.Equal(3, target.SelectedProfessionIds.Count);
Assert.Equal(professions[0].Id, target.SelectedProfessionIds[0]);
Assert.Equal(professions[1].Id, target.SelectedProfessionIds[1]);
Assert.Equal(professions[2].Id, target.SelectedProfessionIds[2]);
}
}
Loading