Compare commits
37
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b45463e56b | ||
|
|
fd2cd68d73 | ||
|
|
b4fbf16869 | ||
|
|
d9130e75a4 | ||
|
|
e2537d77c4 | ||
|
|
fcbc9e78c2 | ||
|
|
9042cd4ead | ||
|
|
1c3de212ff | ||
|
|
72a534a8e0 | ||
|
|
74e55b2f48 | ||
|
|
a3dc58d19b | ||
|
|
5a878bf4b3 | ||
|
|
dee331fb0d | ||
|
|
df859b91b0 | ||
|
|
d77e5d45f6 | ||
|
|
2cca5c15a0 | ||
|
|
ab88656c1e | ||
|
|
6b2b19cb5b | ||
|
|
9c0f642f90 | ||
|
|
701b89d704 | ||
|
|
49e4288eba | ||
|
|
497dedf6ac | ||
|
|
29d8758ede | ||
|
|
6190470fe2 | ||
|
|
ed7e25961c | ||
|
|
e331030bb0 | ||
|
|
3b7830b1bc | ||
|
|
dad2ceb450 | ||
|
|
32cf48bf36 | ||
|
|
502f6f61c1 | ||
|
|
6ca90ed25a | ||
|
|
5192c06627 | ||
|
|
2fd55a55cd | ||
|
|
a2e9fc3f30 | ||
|
|
a83dbbcc54 | ||
|
|
a07fd24ee9 | ||
|
|
2fdb849142 |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"appHostPath": "../src/FiscalOS.AppHost/FiscalOS.AppHost.csproj"
|
||||
}
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"Vue.volar",
|
||||
"vitest.explorer",
|
||||
"ms-playwright.playwright",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"EditorConfig.EditorConfig",
|
||||
"oxc.oxc-vscode",
|
||||
"esbenp.prettier-vscode"
|
||||
]
|
||||
}
|
||||
Vendored
+14
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"dotnet.defaultSolution": "FiscalOS.slnx",
|
||||
"explorer.fileNesting.enabled": true,
|
||||
"explorer.fileNesting.patterns": {
|
||||
"tsconfig.json": "tsconfig.*.json, env.d.ts, typed-router.d.ts",
|
||||
"vite.config.*": "jsconfig*, vitest.config.*, cypress.config.*, playwright.config.*",
|
||||
"package.json": "package-lock.json, pnpm*, .yarnrc*, yarn*, .eslint*, eslint*, .oxlint*, oxlint*, .oxfmt*, .prettier*, prettier*, .editorconfig"
|
||||
},
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll": "explicit"
|
||||
},
|
||||
"editor.formatOnSave": true,
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode"
|
||||
}
|
||||
-131
@@ -1,131 +0,0 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API.Tests", "tests\FiscalOS.API.Tests\FiscalOS.API.Tests.csproj", "{1869E249-07F6-41CD-B143-AF86F022E4A6}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.API", "src\FiscalOS.API\FiscalOS.API.csproj", "{85E0C8F3-AE69-4012-836C-759E9D297762}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.AdminCLI", "src\FiscalOS.AdminCLI\FiscalOS.AdminCLI.csproj", "{C46818A0-1A92-4E4F-8390-48CBA9971745}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Core", "src\FiscalOS.Core\FiscalOS.Core.csproj", "{612496CD-1F25-4804-8C52-49E62D01FFDC}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra", "src\FiscalOS.Infra\FiscalOS.Infra.csproj", "{DAB42870-02A2-4ECB-B07F-6815C52D2435}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Infra.Tests", "tests\FiscalOS.Infra.Tests\FiscalOS.Infra.Tests.csproj", "{48E0737E-0200-468E-ADA7-75893935DBC0}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "FiscalOS.Core.Tests", "tests\FiscalOS.Core.Tests\FiscalOS.Core.Tests.csproj", "{2D9C02A9-C704-4275-88E9-1BBD3AC12700}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Debug|x64 = Debug|x64
|
||||
Debug|x86 = Debug|x86
|
||||
Release|Any CPU = Release|Any CPU
|
||||
Release|x64 = Release|x64
|
||||
Release|x86 = Release|x86
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x64.Build.0 = Release|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6}.Release|x86.Build.0 = Release|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x64.Build.0 = Release|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762}.Release|x86.Build.0 = Release|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x64.Build.0 = Release|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745}.Release|x86.Build.0 = Release|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x64.Build.0 = Release|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC}.Release|x86.Build.0 = Release|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x64.Build.0 = Release|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435}.Release|x86.Build.0 = Release|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x64.Build.0 = Release|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0}.Release|x86.Build.0 = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x64.ActiveCfg = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x64.Build.0 = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x86.ActiveCfg = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Debug|x86.Build.0 = Debug|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x64.ActiveCfg = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x64.Build.0 = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x86.ActiveCfg = Release|Any CPU
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700}.Release|x86.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{1869E249-07F6-41CD-B143-AF86F022E4A6} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{85E0C8F3-AE69-4012-836C-759E9D297762} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{C46818A0-1A92-4E4F-8390-48CBA9971745} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{612496CD-1F25-4804-8C52-49E62D01FFDC} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{DAB42870-02A2-4ECB-B07F-6815C52D2435} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B}
|
||||
{48E0737E-0200-468E-ADA7-75893935DBC0} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
{2D9C02A9-C704-4275-88E9-1BBD3AC12700} = {0AB3BF05-4346-4AA6-1389-037BE0695223}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
@@ -0,0 +1,21 @@
|
||||
<Solution>
|
||||
<Configurations>
|
||||
<Platform Name="Any CPU" />
|
||||
<Platform Name="x64" />
|
||||
<Platform Name="x86" />
|
||||
</Configurations>
|
||||
<Folder Name="/src/">
|
||||
<Project Path="src/FiscalOS.AdminCLI/FiscalOS.AdminCLI.csproj" />
|
||||
<Project Path="src/FiscalOS.API/FiscalOS.API.csproj" />
|
||||
<Project Path="src/FiscalOS.Core/FiscalOS.Core.csproj" />
|
||||
<Project Path="src/FiscalOS.Infra/FiscalOS.Infra.csproj" />
|
||||
</Folder>
|
||||
<Folder Name="/tests/">
|
||||
<Project Path="tests/FiscalOS.API.Tests/FiscalOS.API.Tests.csproj" />
|
||||
<Project Path="tests/FiscalOS.AppHost.Tests/FiscalOS.AppHost.Tests.csproj" />
|
||||
<Project Path="tests/FiscalOS.Core.Tests/FiscalOS.Core.Tests.csproj" />
|
||||
<Project Path="tests/FiscalOS.Infra.Tests/FiscalOS.Infra.Tests.csproj" />
|
||||
</Folder>
|
||||
<Project Path="src/FiscalOS.AppHost/FiscalOS.AppHost.csproj" />
|
||||
<Project Path="src/FiscalOS.ServiceDefaults/FiscalOS.ServiceDefaults.csproj" />
|
||||
</Solution>
|
||||
@@ -3,6 +3,7 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Aspire.Hosting.JavaScript" Version="13.1.1" />
|
||||
<PackageVersion Include="Going.Plaid" Version="6.56.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
@@ -13,5 +14,12 @@
|
||||
<PackageVersion Include="Microsoft.Extensions.Hosting" Version="10.0.2" />
|
||||
<PackageVersion Include="Spectre.Console" Version="0.54.0" />
|
||||
<PackageVersion Include="TestableIO.System.IO.Abstractions.Wrappers" Version="22.1.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.Http.Resilience" Version="10.1.0" />
|
||||
<PackageVersion Include="Microsoft.Extensions.ServiceDiscovery" Version="10.1.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Extensions.Hosting" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Http" Version="1.14.0" />
|
||||
<PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.14.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
</Project>
|
||||
|
||||
@@ -10,7 +10,6 @@ internal static class AccountsExtensions
|
||||
.RequireAuthorization();
|
||||
|
||||
accountsGroup.MapAddEndpoint();
|
||||
accountsGroup.MapConnectEndpoint();
|
||||
|
||||
return accountsGroup;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using FiscalOS.Core.Accounts;
|
||||
|
||||
namespace FiscalOS.API.Accounts.Add;
|
||||
|
||||
internal static class Endpoint
|
||||
@@ -46,6 +44,9 @@ internal static class Endpoint
|
||||
|
||||
var accountMetadata = PlaidAccountMetadata.From(request.PlaidAccountId, request.PlaidAccountName);
|
||||
var account = Account.From(user.Institutions.First().Id, request.PlaidAccountName, accountMetadata);
|
||||
var balance = Balance.From(request.AccountCurrentBalance, request.AccountAvailableBalance, request.AccountCurrencyCode);
|
||||
|
||||
account.AddBalance(balance);
|
||||
user.AddAccount(account);
|
||||
|
||||
await appDbContext.SaveChangesAsync(ct);
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
|
||||
namespace FiscalOS.API.Accounts.Add;
|
||||
|
||||
public record Request : IValidatableObject
|
||||
@@ -6,6 +5,9 @@ public record Request : IValidatableObject
|
||||
public string PlaidInstitutionId { get; init; } = string.Empty;
|
||||
public string PlaidAccountId { get; init; } = string.Empty;
|
||||
public string PlaidAccountName { get; init; } = string.Empty;
|
||||
public decimal AccountCurrentBalance { get; init; }
|
||||
public decimal AccountAvailableBalance { get; init; }
|
||||
public string AccountCurrencyCode { get; init; } = string.Empty;
|
||||
|
||||
public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
|
||||
{
|
||||
@@ -26,5 +28,11 @@ public record Request : IValidatableObject
|
||||
var fieldName = nameof(PlaidAccountName);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(AccountCurrencyCode))
|
||||
{
|
||||
var fieldName = nameof(AccountCurrencyCode);
|
||||
yield return new($"The {fieldName} field is required.", [fieldName]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace FiscalOS.API.Auth;
|
||||
|
||||
internal static class AuthExtensions
|
||||
{
|
||||
private const string RouteGroupPrefix = "/auth";
|
||||
|
||||
public static RouteGroupBuilder MapAuthEndpoints(this WebApplication app)
|
||||
{
|
||||
var authGroup = app.MapGroup(RouteGroupPrefix);
|
||||
|
||||
authGroup.MapLoginEndpoint();
|
||||
|
||||
authGroup.MapRefreshEndpoint()
|
||||
.RequireAuthorization(Schemes.AllowExpiredTokens);
|
||||
|
||||
return authGroup;
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
namespace FiscalOS.API.Login;
|
||||
namespace FiscalOS.API.Auth.Login;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/login";
|
||||
|
||||
public static RouteHandlerBuilder MapLoginEndpoint(this WebApplication app)
|
||||
public static RouteHandlerBuilder MapLoginEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return app.MapPost(Route, HandleAsync);
|
||||
return groupBuilder.MapPost(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace FiscalOS.API.Login;
|
||||
namespace FiscalOS.API.Auth.Login;
|
||||
|
||||
public record Request : IValidatableObject
|
||||
{
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace FiscalOS.API.Login;
|
||||
namespace FiscalOS.API.Auth.Login;
|
||||
|
||||
internal sealed record Response
|
||||
{
|
||||
@@ -1,12 +1,12 @@
|
||||
namespace FiscalOS.API.Refresh;
|
||||
namespace FiscalOS.API.Auth.Refresh;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/refresh";
|
||||
|
||||
public static RouteHandlerBuilder MapRefreshEndpoint(this WebApplication app)
|
||||
public static RouteHandlerBuilder MapRefreshEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return app.MapPost(Route, HandleAsync);
|
||||
return groupBuilder.MapPost(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace FiscalOS.API.Refresh;
|
||||
namespace FiscalOS.API.Auth.Refresh;
|
||||
|
||||
internal sealed record Response
|
||||
{
|
||||
@@ -10,6 +10,7 @@
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FiscalOS.Core\FiscalOS.Core.csproj" />
|
||||
<ProjectReference Include="..\FiscalOS.Infra\FiscalOS.Infra.csproj" />
|
||||
<ProjectReference Include="..\FiscalOS.ServiceDefaults\FiscalOS.ServiceDefaults.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using FiscalOS.Core.Identity;
|
||||
|
||||
namespace FiscalOS.API.Http;
|
||||
|
||||
internal static class HttpContextExtensions
|
||||
|
||||
+1
-3
@@ -1,6 +1,4 @@
|
||||
using FiscalOS.Core.Accounts;
|
||||
|
||||
namespace FiscalOS.API.Accounts.Connect;
|
||||
namespace FiscalOS.API.Institutions.Connect;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
namespace FiscalOS.API.Accounts.Connect;
|
||||
namespace FiscalOS.API.Institutions.Connect;
|
||||
|
||||
public record Request : IValidatableObject
|
||||
{
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace FiscalOS.API.Institutions.Get;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/";
|
||||
|
||||
public static RouteHandlerBuilder MapGetEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return groupBuilder.MapGet(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] AppDbContext appDbContext
|
||||
)
|
||||
{
|
||||
var userId = httpContext.GetUserId();
|
||||
|
||||
var user = await appDbContext.Users
|
||||
.Include(u => u.Institutions)
|
||||
.FirstOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var institutionDtos = user.Institutions
|
||||
.Select(InstitutionDto.FromInstitution)
|
||||
.OrderBy(dto => dto.Name);
|
||||
|
||||
return Results.Ok(Response.From(institutionDtos));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
namespace FiscalOS.API.Institutions.Get;
|
||||
|
||||
internal sealed record Response
|
||||
{
|
||||
public IEnumerable<InstitutionDto> Institutions { get; init; } = [];
|
||||
|
||||
[JsonConstructor]
|
||||
private Response()
|
||||
{
|
||||
}
|
||||
|
||||
public static Response From(IEnumerable<InstitutionDto> institutions)
|
||||
{
|
||||
return new Response
|
||||
{
|
||||
Institutions = institutions,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record InstitutionDto
|
||||
{
|
||||
public string Id { get; init; } = string.Empty;
|
||||
public string Name { get; init; } = string.Empty;
|
||||
|
||||
[JsonConstructor]
|
||||
private InstitutionDto()
|
||||
{
|
||||
}
|
||||
|
||||
public static InstitutionDto FromInstitution(Institution institution)
|
||||
{
|
||||
return new InstitutionDto
|
||||
{
|
||||
Id = institution.Id.ToString(),
|
||||
Name = institution.Name,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
namespace FiscalOS.API.Institutions.GetAvailable;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/{id}/available";
|
||||
|
||||
public static RouteHandlerBuilder MapGetAvailableEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return groupBuilder.MapGet(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext httpContext,
|
||||
[FromRoute] Guid id,
|
||||
[FromServices] AppDbContext appDbContext,
|
||||
[FromServices] PlaidService plaidService,
|
||||
[FromServices] IEncryptor encryptor,
|
||||
CancellationToken ct
|
||||
)
|
||||
{
|
||||
var userId = httpContext.GetUserId();
|
||||
|
||||
var user = await appDbContext.Users
|
||||
.Include(u => u.Institutions.Where(i => i.Id == id))
|
||||
.ThenInclude(i => i.Metadata)
|
||||
.SingleOrDefaultAsync(u => u.Id == userId, ct);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
if (user.Institutions.Any() is false)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
var institution = user.Institutions.First();
|
||||
|
||||
if (institution.Metadata is not PlaidMetadata plaidMetadata)
|
||||
{
|
||||
return Results.BadRequest();
|
||||
}
|
||||
|
||||
var accessToken = await encryptor.DecryptAsyncFor(user, plaidMetadata.EncryptedAccessToken, ct);
|
||||
var accounts = await plaidService.GetAccountsAsync(accessToken);
|
||||
var accountsDtos = accounts.Select(AccountDto.FromPlaidAccount);
|
||||
|
||||
return Results.Ok(Response.From(accountsDtos));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace FiscalOS.API.Institutions.GetAvailable;
|
||||
|
||||
internal sealed record Response
|
||||
{
|
||||
public IEnumerable<AccountDto> Accounts { get; init; } = [];
|
||||
|
||||
[JsonConstructor]
|
||||
private Response()
|
||||
{
|
||||
}
|
||||
|
||||
public static Response From(IEnumerable<AccountDto> accounts)
|
||||
{
|
||||
return new Response
|
||||
{
|
||||
Accounts = accounts
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed record AccountDto
|
||||
{
|
||||
public string ProviderId { get; init; } = string.Empty;
|
||||
public string ProviderName { get; init; } = string.Empty;
|
||||
public decimal CurrentBalance { get; init; }
|
||||
public decimal AvailableBalance { get; init; }
|
||||
public string CurrencyCode { get; init; } = string.Empty;
|
||||
|
||||
[JsonConstructor]
|
||||
private AccountDto()
|
||||
{
|
||||
}
|
||||
|
||||
public static AccountDto FromPlaidAccount(Going.Plaid.Entity.Account plaidAccount)
|
||||
{
|
||||
return new AccountDto
|
||||
{
|
||||
ProviderId = plaidAccount.AccountId,
|
||||
ProviderName = plaidAccount.Name,
|
||||
AvailableBalance = plaidAccount.Available,
|
||||
CurrentBalance = plaidAccount.Current,
|
||||
CurrencyCode = plaidAccount.CurrencyCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace FiscalOS.API.Institutions;
|
||||
|
||||
internal static class InstitutionsExtensions
|
||||
{
|
||||
private const string RouteGroupPrefix = "/institutions";
|
||||
|
||||
public static RouteGroupBuilder MapInstitutionsEndpoints(this WebApplication app)
|
||||
{
|
||||
var institutionsGroup = app.MapGroup(RouteGroupPrefix)
|
||||
.RequireAuthorization();
|
||||
|
||||
institutionsGroup.MapConnectEndpoint();
|
||||
institutionsGroup.MapGetAvailableEndpoint();
|
||||
institutionsGroup.MapLinkEndpoint();
|
||||
institutionsGroup.MapGetEndpoint();
|
||||
|
||||
return institutionsGroup;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
namespace FiscalOS.API.Institutions.Link;
|
||||
|
||||
internal static class Endpoint
|
||||
{
|
||||
private const string Route = "/link";
|
||||
|
||||
public static RouteHandlerBuilder MapLinkEndpoint(this RouteGroupBuilder groupBuilder)
|
||||
{
|
||||
return groupBuilder.MapPost(Route, HandleAsync);
|
||||
}
|
||||
|
||||
private static async Task<IResult> HandleAsync(
|
||||
HttpContext httpContext,
|
||||
[FromServices] AppDbContext appDbContext,
|
||||
[FromServices] PlaidService plaidService
|
||||
)
|
||||
{
|
||||
var userId = httpContext.GetUserId();
|
||||
|
||||
var user = await appDbContext.Users.SingleOrDefaultAsync(u => u.Id == userId);
|
||||
|
||||
if (user is null)
|
||||
{
|
||||
return Results.Unauthorized();
|
||||
}
|
||||
|
||||
var linkToken = await plaidService.CreateLinkTokenAsync(user.Id.ToString());
|
||||
|
||||
return Results.Ok(Response.From(linkToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
namespace FiscalOS.API.Institutions.Link;
|
||||
|
||||
public sealed record Response
|
||||
{
|
||||
public string LinkToken { get; init; } = string.Empty;
|
||||
|
||||
[JsonConstructor]
|
||||
private Response()
|
||||
{
|
||||
}
|
||||
|
||||
public static Response From(string linkToken)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
LinkToken = linkToken,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,9 @@
|
||||
using FiscalOS.ServiceDefaults;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.AddServiceDefaults();
|
||||
|
||||
builder.Services.AddValidation();
|
||||
builder.Services.AddProblemDetails();
|
||||
|
||||
@@ -32,11 +36,10 @@ app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
app.UseStatusCodePages();
|
||||
|
||||
app.MapLoginEndpoint();
|
||||
|
||||
app.MapRefreshEndpoint()
|
||||
.RequireAuthorization(Schemes.AllowExpiredTokens);
|
||||
app.MapDefaultEndpoints();
|
||||
|
||||
app.MapAuthEndpoints();
|
||||
app.MapAccountsEndpoints();
|
||||
app.MapInstitutionsEndpoints();
|
||||
|
||||
app.Run();
|
||||
@@ -18,6 +18,6 @@
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,13 +2,20 @@ global using System.ComponentModel.DataAnnotations;
|
||||
global using System.Security.Claims;
|
||||
global using System.Text.Json.Serialization;
|
||||
|
||||
global using FiscalOS.API.Institutions.Get;
|
||||
global using FiscalOS.API.Accounts;
|
||||
global using FiscalOS.API.Accounts.Add;
|
||||
global using FiscalOS.API.Accounts.Connect;
|
||||
global using FiscalOS.API.Auth;
|
||||
global using FiscalOS.API.Auth.Login;
|
||||
global using FiscalOS.API.Auth.Refresh;
|
||||
global using FiscalOS.API.Http;
|
||||
global using FiscalOS.API.Login;
|
||||
global using FiscalOS.API.Refresh;
|
||||
global using FiscalOS.API.Institutions;
|
||||
global using FiscalOS.API.Institutions.Connect;
|
||||
global using FiscalOS.API.Institutions.GetAvailable;
|
||||
global using FiscalOS.API.Institutions.Link;
|
||||
global using FiscalOS.Core.Accounts;
|
||||
global using FiscalOS.Core.Authentication;
|
||||
global using FiscalOS.Core.Identity;
|
||||
global using FiscalOS.Core.Security;
|
||||
global using FiscalOS.Infra.Accounts.Plaid;
|
||||
global using FiscalOS.Infra.Authentication;
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
using FiscalOS.AppHost;
|
||||
|
||||
var builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
var api = builder.AddProject<Projects.FiscalOS_API>(ProjectNames.API)
|
||||
.WithHttpHealthCheck("/health");
|
||||
|
||||
builder.AddViteApp(ProjectNames.Web, "../FiscalOS.Web")
|
||||
.WithReference(api)
|
||||
.WithHttpsEndpoint(port: 7061, env: "PORT");
|
||||
|
||||
builder.Build().Run();
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Aspire.AppHost.Sdk/13.1.1">
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\FiscalOS.API\FiscalOS.API.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.JavaScript" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<UserSecretsId>80a27726-36cc-4a4d-a55d-11af527158bc</UserSecretsId>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace FiscalOS.AppHost;
|
||||
|
||||
public static class ProjectNames
|
||||
{
|
||||
public const string API = "api";
|
||||
public const string Web = "web";
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/launchsettings.json",
|
||||
"profiles": {
|
||||
"https": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "https://localhost:17104;http://localhost:15068",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "https://localhost:21226",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23228",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22081"
|
||||
}
|
||||
},
|
||||
"http": {
|
||||
"commandName": "Project",
|
||||
"dotnetRunMessages": true,
|
||||
"launchBrowser": true,
|
||||
"applicationUrl": "http://localhost:15068",
|
||||
"environmentVariables": {
|
||||
"ASPNETCORE_ENVIRONMENT": "Development",
|
||||
"DOTNET_ENVIRONMENT": "Development",
|
||||
"ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19221",
|
||||
"ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18206",
|
||||
"ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20097"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,9 @@ public sealed class Account : Entity
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public AccountMetadata? Metadata { get; init; }
|
||||
|
||||
private readonly List<Balance> _balances = [];
|
||||
public IEnumerable<Balance> Balances => _balances;
|
||||
|
||||
public static Account From(Guid institutionId, string name, AccountMetadata accountMetadata)
|
||||
{
|
||||
return new()
|
||||
@@ -17,4 +20,11 @@ public sealed class Account : Entity
|
||||
Metadata = accountMetadata,
|
||||
};
|
||||
}
|
||||
|
||||
public void AddBalance(Balance balance)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(balance);
|
||||
|
||||
_balances.Add(balance);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
namespace FiscalOS.Core.Accounts;
|
||||
|
||||
public sealed class Balance : Entity
|
||||
{
|
||||
public Guid AccountId { get; init; }
|
||||
public decimal Current { get; init; }
|
||||
public decimal Available { get; init; }
|
||||
public string CurrencyCode { get; init; } = string.Empty;
|
||||
|
||||
private Balance()
|
||||
{
|
||||
}
|
||||
|
||||
public static Balance From(
|
||||
decimal current,
|
||||
decimal available,
|
||||
string currencyCode
|
||||
)
|
||||
{
|
||||
return new()
|
||||
{
|
||||
Current = current,
|
||||
Available = available,
|
||||
CurrencyCode = currencyCode,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace FiscalOS.Infra.Accounts.Plaid;
|
||||
|
||||
public static class PlaidExtensions
|
||||
{
|
||||
#pragma warning disable CA1034
|
||||
extension(Going.Plaid.Entity.Account act)
|
||||
{
|
||||
public string CurrencyCode => act.Balances.IsoCurrencyCode
|
||||
?? act.Balances.UnofficialCurrencyCode
|
||||
?? "Unknown";
|
||||
|
||||
public decimal Available => act.Balances.Available.GetValueOrDefault();
|
||||
public decimal Current => act.Balances.Current.GetValueOrDefault();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,7 @@
|
||||
|
||||
|
||||
using System.Reflection;
|
||||
|
||||
namespace FiscalOS.Infra.Accounts.Plaid;
|
||||
|
||||
public sealed class PlaidService
|
||||
@@ -15,6 +19,31 @@ public sealed class PlaidService
|
||||
return new(client);
|
||||
}
|
||||
|
||||
public async Task<string> CreateLinkTokenAsync(string id)
|
||||
{
|
||||
var assemblyName = Assembly.GetExecutingAssembly().GetName().FullName;
|
||||
var environmentName = System.Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT");
|
||||
|
||||
var ltr = await _client.LinkTokenCreateAsync(new()
|
||||
{
|
||||
ClientName = $"{assemblyName}_{environmentName}",
|
||||
Products = [Products.Transactions],
|
||||
CountryCodes = [CountryCode.Us],
|
||||
Language = Language.English,
|
||||
User = new()
|
||||
{
|
||||
ClientUserId = id,
|
||||
},
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (ltr.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new PlaidException("Unable to create link token");
|
||||
}
|
||||
|
||||
return ltr.LinkToken;
|
||||
}
|
||||
|
||||
public async Task<(string ItemId, string AccessToken)> ExchangeTokenAsync(string publicToken)
|
||||
{
|
||||
var ptr = await _client.ItemPublicTokenExchangeAsync(new()
|
||||
@@ -30,6 +59,21 @@ public sealed class PlaidService
|
||||
return (ptr.ItemId, ptr.AccessToken);
|
||||
}
|
||||
|
||||
public async Task<List<Going.Plaid.Entity.Account>> GetAccountsAsync(string accessToken)
|
||||
{
|
||||
var ar = await _client.AccountsGetAsync(new()
|
||||
{
|
||||
AccessToken = accessToken,
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
if (ar.IsSuccessStatusCode is false)
|
||||
{
|
||||
throw new PlaidException("Unable to retrieve accounts");
|
||||
}
|
||||
|
||||
return [.. ar.Accounts];
|
||||
}
|
||||
|
||||
public async Task<ItemWithConsentFields> GetItemAsync(string accessToken)
|
||||
{
|
||||
var ar = await _client.ItemGetAsync(new()
|
||||
@@ -44,4 +88,4 @@ public sealed class PlaidService
|
||||
|
||||
return ar.Item;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,11 @@ public sealed class AppDbContext(
|
||||
.WithOne()
|
||||
.HasForeignKey<AccountMetadata>(static m => m.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
eb.HasMany(static a => a.Balances)
|
||||
.WithOne()
|
||||
.HasForeignKey(static b => b.AccountId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<AccountMetadata>(static eb =>
|
||||
@@ -142,5 +147,13 @@ public sealed class AppDbContext(
|
||||
eb.Property(static m => m.PlaidId);
|
||||
eb.Property(static m => m.PlaidName);
|
||||
});
|
||||
|
||||
modelBuilder.Entity<Balance>(static eb =>
|
||||
{
|
||||
eb.Property(static b => b.AccountId);
|
||||
eb.Property(static b => b.Current);
|
||||
eb.Property(static b => b.Available);
|
||||
eb.Property(static b => b.CurrencyCode);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -5,7 +5,7 @@ public static class ServiceCollectionExtensions
|
||||
public static IServiceCollection AddInfrastructure(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IAuthorizationMiddlewareResultHandler, ProblemDetailsAuthResultHandler>();
|
||||
|
||||
|
||||
services.AddHttpClient();
|
||||
services.AddSingleton(TimeProvider.System);
|
||||
services.AddSingleton<IFileSystem, FileSystem>();
|
||||
|
||||
@@ -1,81 +1,82 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InstitutionAndMetadata : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class InstitutionAndMetadata : Migration
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Institution",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Institution", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Institution_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Institution",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Institution", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Institution_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InstitutionMetadata",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", maxLength: 21, nullable: false),
|
||||
InstitutionId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
PlaidId = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PlaidName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
EncryptedAccessToken = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InstitutionMetadata", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstitutionMetadata_Institution_InstitutionId",
|
||||
column: x => x.InstitutionId,
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
migrationBuilder.CreateTable(
|
||||
name: "InstitutionMetadata",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", maxLength: 21, nullable: false),
|
||||
InstitutionId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
PlaidId = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PlaidName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
EncryptedAccessToken = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_InstitutionMetadata", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_InstitutionMetadata_Institution_InstitutionId",
|
||||
column: x => x.InstitutionId,
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Institution_UserId",
|
||||
table: "Institution",
|
||||
column: "UserId");
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Institution_UserId",
|
||||
table: "Institution",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstitutionMetadata_InstitutionId",
|
||||
table: "InstitutionMetadata",
|
||||
column: "InstitutionId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "InstitutionMetadata");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Institution");
|
||||
}
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_InstitutionMetadata_InstitutionId",
|
||||
table: "InstitutionMetadata",
|
||||
column: "InstitutionId",
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "InstitutionMetadata");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Institution");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,92 +1,93 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AccountAndMetadata : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AccountAndMetadata : Migration
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Account",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
InstituionId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Account", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Account_Institution_InstituionId",
|
||||
column: x => x.InstituionId,
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Account_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Account",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
UserId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
InstituionId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Name = table.Column<string>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Account", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Account_Institution_InstituionId",
|
||||
column: x => x.InstituionId,
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
table.ForeignKey(
|
||||
name: "FK_Account_Users_UserId",
|
||||
column: x => x.UserId,
|
||||
principalTable: "Users",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AccountMetadata",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", maxLength: 21, nullable: false),
|
||||
AccountId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
PlaidId = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PlaidName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AccountMetadata", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AccountMetadata_Account_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Account",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
migrationBuilder.CreateTable(
|
||||
name: "AccountMetadata",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Type = table.Column<string>(type: "TEXT", maxLength: 21, nullable: false),
|
||||
AccountId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
PlaidId = table.Column<string>(type: "TEXT", nullable: true),
|
||||
PlaidName = table.Column<string>(type: "TEXT", nullable: true),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_AccountMetadata", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_AccountMetadata_Account_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Account",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Account_InstituionId",
|
||||
table: "Account",
|
||||
column: "InstituionId");
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Account_InstituionId",
|
||||
table: "Account",
|
||||
column: "InstituionId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Account_UserId",
|
||||
table: "Account",
|
||||
column: "UserId");
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Account_UserId",
|
||||
table: "Account",
|
||||
column: "UserId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountMetadata_AccountId",
|
||||
table: "AccountMetadata",
|
||||
column: "AccountId",
|
||||
unique: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AccountMetadata");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Account");
|
||||
}
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_AccountMetadata_AccountId",
|
||||
table: "AccountMetadata",
|
||||
column: "AccountId",
|
||||
unique: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "AccountMetadata");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "Account");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,59 +4,59 @@
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixAccountInstitutionColumnName : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class FixAccountInstitutionColumnName : Migration
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Account_Institution_InstituionId",
|
||||
table: "Account");
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Account_Institution_InstituionId",
|
||||
table: "Account");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "InstituionId",
|
||||
table: "Account",
|
||||
newName: "InstitutionId");
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "InstituionId",
|
||||
table: "Account",
|
||||
newName: "InstitutionId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Account_InstituionId",
|
||||
table: "Account",
|
||||
newName: "IX_Account_InstitutionId");
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Account_InstituionId",
|
||||
table: "Account",
|
||||
newName: "IX_Account_InstitutionId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Account_Institution_InstitutionId",
|
||||
table: "Account",
|
||||
column: "InstitutionId",
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Account_Institution_InstitutionId",
|
||||
table: "Account");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "InstitutionId",
|
||||
table: "Account",
|
||||
newName: "InstituionId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Account_InstitutionId",
|
||||
table: "Account",
|
||||
newName: "IX_Account_InstituionId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Account_Institution_InstituionId",
|
||||
table: "Account",
|
||||
column: "InstituionId",
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Account_Institution_InstitutionId",
|
||||
table: "Account",
|
||||
column: "InstitutionId",
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropForeignKey(
|
||||
name: "FK_Account_Institution_InstitutionId",
|
||||
table: "Account");
|
||||
|
||||
migrationBuilder.RenameColumn(
|
||||
name: "InstitutionId",
|
||||
table: "Account",
|
||||
newName: "InstituionId");
|
||||
|
||||
migrationBuilder.RenameIndex(
|
||||
name: "IX_Account_InstitutionId",
|
||||
table: "Account",
|
||||
newName: "IX_Account_InstituionId");
|
||||
|
||||
migrationBuilder.AddForeignKey(
|
||||
name: "FK_Account_Institution_InstituionId",
|
||||
table: "Account",
|
||||
column: "InstituionId",
|
||||
principalTable: "Institution",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+364
@@ -0,0 +1,364 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FiscalOS.Infra.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260214112234_AddBalancesWithConnectionToAccounts")]
|
||||
partial class AddBalancesWithConnectionToAccounts
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AccountMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Available")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Current")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.ToTable("Balance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InstitutionMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EncryptedDataKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EncryptionKeyId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("HashedPassword")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
|
||||
|
||||
b.Property<string>("EncryptedAccessToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
|
||||
.WithMany()
|
||||
.HasForeignKey("InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Accounts")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithMany("Balances")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Institutions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Navigation("Balances");
|
||||
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
|
||||
b.Navigation("Institutions");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
using System;
|
||||
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBalancesWithConnectionToAccounts : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "Balance",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
AccountId = table.Column<Guid>(type: "TEXT", nullable: false),
|
||||
Current = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
Available = table.Column<decimal>(type: "TEXT", nullable: false),
|
||||
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
|
||||
UpdatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_Balance", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_Balance_Account_AccountId",
|
||||
column: x => x.AccountId,
|
||||
principalTable: "Account",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_Balance_AccountId",
|
||||
table: "Balance",
|
||||
column: "AccountId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "Balance");
|
||||
}
|
||||
}
|
||||
}
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using FiscalOS.Infra.Data;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260214122622_AddCurrencyCodeToBalanceModel")]
|
||||
partial class AddCurrencyCodeToBalanceModel
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Account");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AccountMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("AccountMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Available")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrencyCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Current")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.ToTable("Balance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("InstitutionId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasMaxLength(21)
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("InstitutionId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("InstitutionMetadata");
|
||||
|
||||
b.HasDiscriminator<string>("Type").HasValue("InstitutionMetadata");
|
||||
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("ExpiresAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("Revoked")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Token")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Token")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("RefreshTokens");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EncryptedDataKey")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("EncryptionKeyId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("HashedPassword")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Username")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("Username")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidAccountMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.AccountMetadata");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Infra.Accounts.Plaid.PlaidMetadata", b =>
|
||||
{
|
||||
b.HasBaseType("FiscalOS.Core.Accounts.InstitutionMetadata");
|
||||
|
||||
b.Property<string>("EncryptedAccessToken")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PlaidName")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasDiscriminator().HasValue("Plaid");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", "Institution")
|
||||
.WithMany()
|
||||
.HasForeignKey("InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Accounts")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Institution");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.AccountMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.AccountMetadata", "AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithMany("Balances")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
.WithMany("Institutions")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.InstitutionMetadata", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Institution", null)
|
||||
.WithOne("Metadata")
|
||||
.HasForeignKey("FiscalOS.Core.Accounts.InstitutionMetadata", "InstitutionId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.RefreshToken", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", "User")
|
||||
.WithMany("RefreshTokens")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Navigation("Balances");
|
||||
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
{
|
||||
b.Navigation("Accounts");
|
||||
|
||||
b.Navigation("Institutions");
|
||||
|
||||
b.Navigation("RefreshTokens");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace FiscalOS.Infra.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddCurrencyCodeToBalanceModel : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "CurrencyCode",
|
||||
table: "Balance",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "CurrencyCode",
|
||||
table: "Balance");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,38 @@ namespace FiscalOS.Infra.Migrations
|
||||
b.UseTphMappingStrategy();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid>("AccountId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Available")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("CurrencyCode")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<decimal>("Current")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTimeOffset>("UpdatedAt")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("AccountId");
|
||||
|
||||
b.ToTable("Balance");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -269,6 +301,15 @@ namespace FiscalOS.Infra.Migrations
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Balance", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Accounts.Account", null)
|
||||
.WithMany("Balances")
|
||||
.HasForeignKey("AccountId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.HasOne("FiscalOS.Core.Identity.User", null)
|
||||
@@ -300,14 +341,14 @@ namespace FiscalOS.Infra.Migrations
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Account", b =>
|
||||
{
|
||||
b.Navigation("Metadata")
|
||||
.IsRequired();
|
||||
b.Navigation("Balances");
|
||||
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Accounts.Institution", b =>
|
||||
{
|
||||
b.Navigation("Metadata")
|
||||
.IsRequired();
|
||||
b.Navigation("Metadata");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("FiscalOS.Core.Identity.User", b =>
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsAspireSharedProject>true</IsAspireSharedProject>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,89 @@
|
||||
namespace FiscalOS.ServiceDefaults;
|
||||
|
||||
public static class ServiceDefaultsExtensions
|
||||
{
|
||||
private const string HealthEndpointPath = "/health";
|
||||
private const string AlivenessEndpointPath = "/alive";
|
||||
|
||||
public static TBuilder AddServiceDefaults<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.ConfigureOpenTelemetry();
|
||||
|
||||
builder.AddDefaultHealthChecks();
|
||||
|
||||
builder.Services.AddServiceDiscovery();
|
||||
|
||||
builder.Services.ConfigureHttpClientDefaults(static http =>
|
||||
{
|
||||
http.AddStandardResilienceHandler();
|
||||
http.AddServiceDiscovery();
|
||||
});
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder ConfigureOpenTelemetry<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Logging.AddOpenTelemetry(logging =>
|
||||
{
|
||||
logging.IncludeFormattedMessage = true;
|
||||
logging.IncludeScopes = true;
|
||||
});
|
||||
|
||||
builder.Services.AddOpenTelemetry()
|
||||
.WithMetrics(metrics => metrics.AddAspNetCoreInstrumentation()
|
||||
.AddHttpClientInstrumentation()
|
||||
.AddRuntimeInstrumentation()
|
||||
)
|
||||
.WithTracing(tracing => tracing.AddSource(builder.Environment.ApplicationName)
|
||||
.AddAspNetCoreInstrumentation(tracing =>
|
||||
tracing.Filter = context =>
|
||||
context.Request.Path.StartsWithSegments(HealthEndpointPath, StringComparison.InvariantCultureIgnoreCase) is false &&
|
||||
context.Request.Path.StartsWithSegments(AlivenessEndpointPath, StringComparison.InvariantCultureIgnoreCase) is false
|
||||
)
|
||||
.AddHttpClientInstrumentation()
|
||||
);
|
||||
|
||||
builder.AddOpenTelemetryExporters();
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static TBuilder AddDefaultHealthChecks<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
builder.Services.AddHealthChecks()
|
||||
.AddCheck("self", static () => HealthCheckResult.Healthy(), ["live"]);
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
public static WebApplication MapDefaultEndpoints(this WebApplication app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.MapHealthChecks(HealthEndpointPath);
|
||||
|
||||
app.MapHealthChecks(AlivenessEndpointPath, new HealthCheckOptions
|
||||
{
|
||||
Predicate = static r => r.Tags.Contains("live")
|
||||
});
|
||||
}
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static TBuilder AddOpenTelemetryExporters<TBuilder>(this TBuilder builder) where TBuilder : IHostApplicationBuilder
|
||||
{
|
||||
var useOtlpExporter = !string.IsNullOrWhiteSpace(builder.Configuration["OTEL_EXPORTER_OTLP_ENDPOINT"]);
|
||||
|
||||
if (useOtlpExporter)
|
||||
{
|
||||
builder.Services.AddOpenTelemetry().UseOtlpExporter();
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
global using Microsoft.AspNetCore.Builder;
|
||||
global using Microsoft.AspNetCore.Diagnostics.HealthChecks;
|
||||
global using Microsoft.Extensions.DependencyInjection;
|
||||
global using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
global using Microsoft.Extensions.Hosting;
|
||||
global using Microsoft.Extensions.Logging;
|
||||
|
||||
global using OpenTelemetry;
|
||||
global using OpenTelemetry.Metrics;
|
||||
global using OpenTelemetry.Trace;
|
||||
@@ -0,0 +1,8 @@
|
||||
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||
charset = utf-8
|
||||
indent_size = 2
|
||||
indent_style = space
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
end_of_line = lf
|
||||
max_line_length = 100
|
||||
@@ -0,0 +1 @@
|
||||
* text=auto eol=lf
|
||||
@@ -0,0 +1,42 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
.DS_Store
|
||||
dist
|
||||
dist-ssr
|
||||
coverage
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
|
||||
*.tsbuildinfo
|
||||
|
||||
.eslintcache
|
||||
|
||||
# Cypress
|
||||
/cypress/videos/
|
||||
/cypress/screenshots/
|
||||
|
||||
# Vitest
|
||||
__screenshots__/
|
||||
|
||||
# Vite
|
||||
*.timestamp-*-*.mjs
|
||||
|
||||
test-results/
|
||||
playwright-report/
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
|
||||
"env": {
|
||||
"browser": true
|
||||
},
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/prettierrc",
|
||||
"semi": true,
|
||||
"tabWidth": 2,
|
||||
"singleQuote": true,
|
||||
"printWidth": 100,
|
||||
"trailingComma": "es5",
|
||||
"arrowParens": "avoid",
|
||||
"bracketSameLine": false,
|
||||
"jsxSingleQuote": true,
|
||||
"embeddedLanguageFormatting": "auto",
|
||||
"endOfLine": "auto",
|
||||
"vueIndentScriptAndStyle": true,
|
||||
"singleAttributePerLine": true
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": ["./**/*"]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test('visits the app root url', async ({ page }) => {
|
||||
await page.goto('/')
|
||||
await expect(page.locator('h1')).toHaveText('You did it!')
|
||||
})
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,27 @@
|
||||
import { globalIgnores } from 'eslint/config'
|
||||
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||
import pluginVue from 'eslint-plugin-vue'
|
||||
import pluginPlaywright from 'eslint-plugin-playwright'
|
||||
import pluginVitest from '@vitest/eslint-plugin'
|
||||
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||
import skipFormatting from 'eslint-config-prettier/flat'
|
||||
|
||||
export default defineConfigWithVueTs(
|
||||
{
|
||||
name: 'app/files-to-lint',
|
||||
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||
},
|
||||
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||
...pluginVue.configs['flat/essential'],
|
||||
vueTsConfigs.recommended,
|
||||
{
|
||||
...pluginPlaywright.configs['flat/recommended'],
|
||||
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
|
||||
},
|
||||
{
|
||||
...pluginVitest.configs.recommended,
|
||||
files: ['src/**/__tests__/*'],
|
||||
},
|
||||
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||
skipFormatting,
|
||||
)
|
||||
@@ -0,0 +1,13 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>FiscalOS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+7779
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"name": "fiscalos-web",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "run-p type-check \"build-only {@}\" --",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "vitest",
|
||||
"test:e2e": "playwright test",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --build",
|
||||
"lint": "run-s lint:*",
|
||||
"lint:oxlint": "oxlint . --fix",
|
||||
"lint:eslint": "eslint . --fix --cache",
|
||||
"format": "prettier --write --experimental-cli src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@jcss/vue-plaid-link": "^1.1.3",
|
||||
"jwt-decode": "^4.0.0",
|
||||
"pinia": "^3.0.4",
|
||||
"ts-results": "^3.3.0",
|
||||
"vue": "beta",
|
||||
"vue-router": "^5.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.58.2",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/jsdom": "^27.0.0",
|
||||
"@types/node": "^24.10.13",
|
||||
"@vitejs/plugin-vue": "^6.0.4",
|
||||
"@vitejs/plugin-vue-jsx": "^5.1.4",
|
||||
"@vitest/eslint-plugin": "^1.6.9",
|
||||
"@vue/eslint-config-typescript": "^14.6.0",
|
||||
"@vue/test-utils": "^2.4.6",
|
||||
"@vue/tsconfig": "^0.8.1",
|
||||
"eslint": "^9.39.2",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.46.0",
|
||||
"eslint-plugin-playwright": "^2.5.1",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.6.1",
|
||||
"jsdom": "^28.1.0",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxlint": "~1.47.0",
|
||||
"prettier": "3.8.1",
|
||||
"typescript": "~5.9.3",
|
||||
"vite": "beta",
|
||||
"vite-plugin-mkcert": "^1.17.9",
|
||||
"vite-plugin-vue-devtools": "^8.0.6",
|
||||
"vitest": "^4.0.18",
|
||||
"vue-tsc": "^3.2.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"overrides": {
|
||||
"vue": "beta",
|
||||
"@vue/compiler-core": "beta",
|
||||
"@vue/compiler-dom": "beta",
|
||||
"@vue/compiler-sfc": "beta",
|
||||
"@vue/compiler-ssr": "beta",
|
||||
"@vue/compiler-vapor": "beta",
|
||||
"@vue/reactivity": "beta",
|
||||
"@vue/runtime-core": "beta",
|
||||
"@vue/runtime-dom": "beta",
|
||||
"@vue/runtime-vapor": "beta",
|
||||
"@vue/server-renderer": "beta",
|
||||
"@vue/shared": "beta",
|
||||
"@vue/compat": "beta"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import process from 'node:process'
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 30 * 1000,
|
||||
expect: {
|
||||
timeout: 5000,
|
||||
},
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
use: {
|
||||
actionTimeout: 0,
|
||||
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
|
||||
trace: 'on-first-retry',
|
||||
headless: !!process.env.CI,
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'webkit',
|
||||
use: {
|
||||
...devices['Desktop Safari'],
|
||||
},
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: process.env.CI ? 'npm run preview' : 'npm run dev',
|
||||
port: process.env.CI ? 4173 : 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterView />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,116 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
|
||||
import { flushPromises, mount } from '@vue/test-utils';
|
||||
import LoginForm from '@/components/LoginForm.vue';
|
||||
|
||||
describe('LoginForm', () => {
|
||||
it('should have username field', () => {
|
||||
const wrapper = mount(LoginForm);
|
||||
|
||||
const username = wrapper.get('#username');
|
||||
|
||||
expect(username.element.tagName).toBe('INPUT');
|
||||
expect(username.attributes()['type']).toBe('text');
|
||||
});
|
||||
|
||||
it('should have password field', () => {
|
||||
const wrapper = mount(LoginForm);
|
||||
|
||||
const password = wrapper.get('#password');
|
||||
|
||||
expect(password.element.tagName).toBe('INPUT');
|
||||
expect(password.attributes()['type']).toBe('password');
|
||||
});
|
||||
|
||||
it('should require username field on submission', async () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
|
||||
expect(wrapper.text()).toContain('Username is required');
|
||||
});
|
||||
|
||||
it('should require password field on submission', async () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
|
||||
expect(wrapper.text()).toContain('Password is required');
|
||||
});
|
||||
|
||||
it('should reset error state when new input is entered', async () => {
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
});
|
||||
|
||||
await wrapper.get('button').trigger('click');
|
||||
|
||||
expect(wrapper.text()).toContain('Username is required');
|
||||
expect(wrapper.text()).toContain('Password is required');
|
||||
|
||||
const usernameInput = wrapper.get('#username');
|
||||
const passwordInput = wrapper.get('#password');
|
||||
|
||||
await usernameInput.setValue('Stevan');
|
||||
await passwordInput.setValue('password');
|
||||
|
||||
expect(wrapper.text()).not.toContain('Username is required');
|
||||
expect(wrapper.text()).not.toContain('Password is required');
|
||||
});
|
||||
|
||||
it('should disable login button while submitting', async () => {
|
||||
let resolveSubmit;
|
||||
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
onValidSubmit: () => new Promise(resolve => {
|
||||
resolveSubmit = resolve;
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const usernameInput = wrapper.get('#username');
|
||||
const passwordInput = wrapper.get('#password');
|
||||
const submitButton = wrapper.get('button');
|
||||
|
||||
await usernameInput.setValue('Stevan');
|
||||
await passwordInput.setValue('password');
|
||||
await submitButton.trigger('click');
|
||||
|
||||
expect('disabled' in submitButton.attributes()).toBe(true);
|
||||
|
||||
resolveSubmit!();
|
||||
await flushPromises();
|
||||
|
||||
expect('disabled' in submitButton.attributes()).toBe(false);
|
||||
});
|
||||
|
||||
it('should call the onValidSubmit function when submitted with valid state', async () => {
|
||||
let wasCalled = false;
|
||||
|
||||
const wrapper = mount(LoginForm, {
|
||||
attachTo: document.body,
|
||||
props: {
|
||||
onValidSubmit: () => new Promise(resolve => {
|
||||
wasCalled = true;
|
||||
resolve();
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const usernameInput = wrapper.get('#username');
|
||||
const passwordInput = wrapper.get('#password');
|
||||
const submitButton = wrapper.get('button');
|
||||
|
||||
await usernameInput.setValue('Stevan');
|
||||
await passwordInput.setValue('password');
|
||||
await submitButton.trigger('click');
|
||||
|
||||
expect(wasCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
@import './reset.css';
|
||||
|
||||
@font-face {
|
||||
font-family: 'CaskaydiaCove NFM';
|
||||
src: url('../fonts/CaskaydiaCoveNFM-Regular.eot');
|
||||
src:
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.eot?#iefix') format('embedded-opentype'),
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.woff2') format('woff2'),
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.woff') format('woff'),
|
||||
url('../fonts/CaskaydiaCoveNFM-Regular.svg#CaskaydiaCoveNFM-Regular') format('svg');
|
||||
font-weight: normal;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
:root {
|
||||
--palette-lavender: #b39cd0;
|
||||
--palette-rose: #d09c9f;
|
||||
--palette-sage: #b9d09c;
|
||||
--palette-mint: #9cd0cd;
|
||||
|
||||
--neutral-white: #ffffff;
|
||||
--neutral-100: #f5f5f5;
|
||||
--neutral-200: #e5e5e5;
|
||||
--neutral-800: #262626;
|
||||
--neutral-900: #171717;
|
||||
--neutral-black: #000000;
|
||||
|
||||
--bg-app: var(--neutral-white);
|
||||
--bg-surface: var(--neutral-100);
|
||||
--bg-element: var(--neutral-200);
|
||||
|
||||
--text-primary: var(--neutral-black);
|
||||
--text-secondary: var(--neutral-800);
|
||||
--border-subtle: var(--neutral-200);
|
||||
|
||||
--brand-primary: var(--palette-lavender);
|
||||
--brand-secondary: var(--palette-rose);
|
||||
--brand-accent: var(--palette-mint);
|
||||
--state-success: var(--palette-sage);
|
||||
--state-error: var(--palette-rose);
|
||||
|
||||
font-family: 'CaskaydiaCove NFM', monospace;
|
||||
font-size: 16px;
|
||||
text-rendering: optimizeLegibility;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--bg-app: var(--neutral-black);
|
||||
--bg-surface: var(--neutral-900);
|
||||
--bg-element: var(--neutral-800);
|
||||
|
||||
--text-primary: var(--neutral-white);
|
||||
--text-secondary: var(--neutral-200);
|
||||
--border-subtle: var(--neutral-800);
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-app);
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#app {
|
||||
height: 100%;
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
input {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
ul {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
|
After Width: | Height: | Size: 6.4 MiB |
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,147 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue';
|
||||
|
||||
export type LoginFormState = {
|
||||
isLoggingIn: boolean;
|
||||
username: {
|
||||
value: string;
|
||||
error: string;
|
||||
};
|
||||
password: {
|
||||
value: string;
|
||||
error: string;
|
||||
};
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
onValidSubmit?: (formState: LoginFormState) => Promise<void> | void;
|
||||
}>();
|
||||
|
||||
const formState = ref<LoginFormState>({
|
||||
isLoggingIn: false,
|
||||
username: {
|
||||
value: '',
|
||||
error: '',
|
||||
},
|
||||
password: {
|
||||
value: '',
|
||||
error: '',
|
||||
},
|
||||
});
|
||||
|
||||
function handleUsernameInput(e: Event) {
|
||||
formState.value.username.value = (e.currentTarget as HTMLInputElement).value;
|
||||
|
||||
if (formState.value.username.error.trim()) {
|
||||
formState.value.username.error = '';
|
||||
}
|
||||
}
|
||||
|
||||
function handlePasswordInput(e: Event) {
|
||||
formState.value.password.value = (e.currentTarget as HTMLInputElement).value;
|
||||
|
||||
if (formState.value.password.error.trim()) {
|
||||
formState.value.password.error = '';
|
||||
}
|
||||
}
|
||||
|
||||
function validateFormState() {
|
||||
let isValid = true;
|
||||
|
||||
if (!formState.value.username.value.trim()) {
|
||||
formState.value.username.error = 'Username is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (!formState.value.password.value.trim()) {
|
||||
formState.value.password.error = 'Password is required';
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
formState.value.isLoggingIn = true;
|
||||
|
||||
try {
|
||||
if (validateFormState() === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.onValidSubmit !== undefined) {
|
||||
await props.onValidSubmit(formState.value);
|
||||
}
|
||||
|
||||
} finally {
|
||||
formState.value.isLoggingIn = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div>
|
||||
<label for="username">Username</label>
|
||||
<input type="text" name="username" id="username" :value="formState.username.value" @input="handleUsernameInput" />
|
||||
<div class="error">{{ formState.username.error }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label for="password">Password</label>
|
||||
<input type="password" name="password" id="password" :value="formState.password.value"
|
||||
@input="handlePasswordInput" />
|
||||
<div class="error">{{ formState.password.error }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button type="submit" :disabled="formState.isLoggingIn">
|
||||
Login
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
form,
|
||||
form>div {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background-color: var(--bg-surface);
|
||||
}
|
||||
|
||||
form {
|
||||
gap: 1rem;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
form>div {
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
form>div label {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
form>div input {
|
||||
padding: 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
border: 1px solid black;
|
||||
background-color: var(--bg-element);
|
||||
}
|
||||
|
||||
form>div button {
|
||||
background-color: var(--brand-primary);
|
||||
padding: 0.5rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
form>div button:disabled {
|
||||
opacity: 50%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
form>div .error {
|
||||
color: var(--state-error);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,106 @@
|
||||
<script setup lang="ts">
|
||||
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { computed } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const router = useRouter();
|
||||
|
||||
const asideClasses = computed(() => ({
|
||||
collapsed: userStore.user?.sidebarCollapsed,
|
||||
}));
|
||||
|
||||
function handleToggleButtonClick() {
|
||||
userStore.toggleSidebar();
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
// TODO: Also need to log user
|
||||
// out on the server...which is
|
||||
// basically just clearing
|
||||
// refresh token cookie and revoking
|
||||
// it in the database
|
||||
userStore.logUserOut();
|
||||
router.push({ path: '/public/login' });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside :class="asideClasses">
|
||||
<button
|
||||
@click="handleToggleButtonClick"
|
||||
type="button"
|
||||
class="toggle-button"
|
||||
>
|
||||
<LeftArrowIcon />
|
||||
</button>
|
||||
<button
|
||||
class="logout-button"
|
||||
type="button"
|
||||
@click="handleLogout"
|
||||
>
|
||||
Logout
|
||||
</button>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
aside {
|
||||
--sidebar-width: 15.625rem;
|
||||
--button-size: 1.5rem;
|
||||
--transition-duration: 0.5s;
|
||||
--transition-function: ease-in-out;
|
||||
position: relative;
|
||||
width: var(--sidebar-width);
|
||||
height: 100%;
|
||||
z-index: 999;
|
||||
background: var(--bg-surface);
|
||||
transition-property: width, transform;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-function);
|
||||
}
|
||||
|
||||
@media screen and (max-width: 48rem) {
|
||||
aside {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.logout-button {
|
||||
background: var(--bg-element);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
|
||||
.toggle-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: absolute;
|
||||
top: 3rem;
|
||||
left: calc(-1 * var(--button-size) / 2);
|
||||
height: var(--button-size);
|
||||
width: var(--button-size);
|
||||
background: var(--bg-element);
|
||||
border-radius: 50%;
|
||||
transition-property: transform;
|
||||
transition-duration: var(--transition-duration);
|
||||
transition-timing-function: var(--transition-function);
|
||||
}
|
||||
|
||||
aside.collapsed {
|
||||
width: calc(0.125rem + var(--button-size) / 2);
|
||||
}
|
||||
|
||||
aside.collapsed .toggle-button {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.toggle-button svg {
|
||||
--size: 1rem;
|
||||
width: var(--size);
|
||||
height: var(--size);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,26 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
import NavSidebar from './NavSidebar.vue';
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="layout">
|
||||
<main>
|
||||
<RouterView />
|
||||
</main>
|
||||
<NavSidebar />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.layout {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
main {
|
||||
flex: 1;
|
||||
padding: 1rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterView } from 'vue-router';
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<RouterView />
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,6 @@
|
||||
<template>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 640">
|
||||
<path
|
||||
d="M566.6 342.6C579.1 330.1 579.1 309.8 566.6 297.3L406.6 137.3C394.1 124.8 373.8 124.8 361.3 137.3C348.8 149.8 348.8 170.1 361.3 182.6L466.7 288L96 288C78.3 288 64 302.3 64 320C64 337.7 78.3 352 96 352L466.7 352L361.3 457.4C348.8 469.9 348.8 490.2 361.3 502.7C373.8 515.2 394.1 515.2 406.6 502.7L566.6 342.7z" />
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
import { AuthServiceFactoryKey } from "@/services/authService";
|
||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
||||
import type { UserStore } from "@/stores/userStore";
|
||||
import { inject } from "vue";
|
||||
|
||||
export function useAuthService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const authServiceFactory = inject(AuthServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error("Failed to inject client factory.")
|
||||
}
|
||||
|
||||
if (authServiceFactory === undefined) {
|
||||
throw new Error("Failed to inject auth service factory.")
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}`},
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const authService = authServiceFactory.create(client);
|
||||
|
||||
return authService;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ClientConfig, ClientFactoryKey } from "@/services/client";
|
||||
import { InstituionServiceFactoryKey } from "@/services/institutionService";
|
||||
import type { UserStore } from "@/stores/userStore";
|
||||
import { inject } from "vue";
|
||||
|
||||
export function useInstitutionService(store: UserStore) {
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const institutionServiceFactory = inject(InstituionServiceFactoryKey);
|
||||
|
||||
if (clientFactory === undefined) {
|
||||
throw new Error("Failed to inject client factory.")
|
||||
}
|
||||
|
||||
if (institutionServiceFactory === undefined) {
|
||||
throw new Error("Failed to inject institution service factory.")
|
||||
}
|
||||
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${store.user?.token}`},
|
||||
true,
|
||||
store.refreshAccessToken
|
||||
);
|
||||
const client = clientFactory.create(clientConfig);
|
||||
const institutionService = institutionServiceFactory.create(client);
|
||||
|
||||
return institutionService;
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import './assets/css/main.css';
|
||||
|
||||
import { createApp } from "vue";
|
||||
import { createPinia } from "pinia";
|
||||
|
||||
import App from "./App.vue";
|
||||
import router from "./router";
|
||||
import { ClientFactory, ClientFactoryKey } from "./services/client";
|
||||
import { AuthServiceFactory, AuthServiceFactoryKey } from "./services/authService";
|
||||
import { InstituionServiceFactoryKey, InstitutionServiceFactory } from './services/institutionService';
|
||||
|
||||
const app = createApp(App);
|
||||
|
||||
app.provide(ClientFactoryKey, new ClientFactory());
|
||||
app.provide(AuthServiceFactoryKey, new AuthServiceFactory());
|
||||
app.provide(InstituionServiceFactoryKey, new InstitutionServiceFactory());
|
||||
|
||||
app.use(createPinia());
|
||||
app.use(router);
|
||||
|
||||
app.mount("#app");
|
||||
@@ -0,0 +1,70 @@
|
||||
import { AuthService } from '@/services/authService';
|
||||
import { Client, ClientConfig } from '@/services/client';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { createRouter, createWebHistory } from 'vue-router';
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
routes: [
|
||||
{
|
||||
path: '/public',
|
||||
component: () => import('../components/PublicLayout.vue'),
|
||||
redirect: '/public/login',
|
||||
beforeEnter: () => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
if (userStore.user) {
|
||||
return { path: '/' };
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'login',
|
||||
component: () => import('../views/LoginView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../components/ProtectedLayout.vue'),
|
||||
beforeEnter: async () => {
|
||||
const userStore = useUserStore();
|
||||
|
||||
if (userStore.user === null) {
|
||||
return { path: '/public/login' };
|
||||
}
|
||||
|
||||
const isExpired = userStore.user.expiresAtInSeconds < Date.now() / 1000;
|
||||
|
||||
if (isExpired) {
|
||||
const clientConfig = new ClientConfig(
|
||||
{ Authorization: `Bearer ${userStore.user.token}` },
|
||||
true
|
||||
);
|
||||
const client = new Client(clientConfig);
|
||||
const authService = new AuthService(client);
|
||||
const refreshResult = await authService.refreshToken();
|
||||
|
||||
if (refreshResult.err) {
|
||||
userStore.logUserOut();
|
||||
return { path: '/public/login' };
|
||||
}
|
||||
|
||||
userStore.logUserIn(refreshResult.val.accessToken);
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: '/',
|
||||
component: () => import('../views/HomeView.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Err, Ok, Result } from 'ts-results';
|
||||
import { type InjectionKey } from 'vue';
|
||||
import { ClientRequestWithBody, type IClient } from './client';
|
||||
|
||||
type AuthServiceFactoryKeyType = InjectionKey<IAuthServiceFactory>;
|
||||
|
||||
export const AuthServiceFactoryKey: AuthServiceFactoryKeyType = Symbol('AuthServiceFactory');
|
||||
|
||||
export interface IAuthServiceFactory {
|
||||
create: (client: IClient) => IAuthService;
|
||||
}
|
||||
|
||||
export class AuthServiceFactory implements IAuthServiceFactory {
|
||||
create(client: IClient): IAuthService {
|
||||
return new AuthService(client);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IAuthService {
|
||||
login: (username: string, password: string) => Promise<Result<LoginResponse, Error[]>>;
|
||||
refreshToken: () => Promise<Result<LoginResponse, Error[]>>;
|
||||
}
|
||||
|
||||
export class AuthService implements IAuthService {
|
||||
private readonly client: IClient;
|
||||
private readonly endpoints = {
|
||||
login: '/api/auth/login',
|
||||
refreshToken: '/api/auth/refresh',
|
||||
};
|
||||
|
||||
constructor(client: IClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async login(username: string, password: string) {
|
||||
const request = new ClientRequestWithBody(
|
||||
this.endpoints.login,
|
||||
undefined,
|
||||
new LoginRequest(username, password)
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.status === 400) {
|
||||
const body = await res.json();
|
||||
const validationErrors = body.errors as Record<string, string[]>;
|
||||
const errors = Object.values(validationErrors)
|
||||
.flat()
|
||||
.map(e => new Error(e));
|
||||
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return Err([new Error('Email/Password combination is not valid')]);
|
||||
}
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Login failed. Please try again.')]);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
return Ok(body as LoginResponse);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Login failed. Please try again.')]);
|
||||
}
|
||||
}
|
||||
|
||||
async refreshToken() {
|
||||
const request = new ClientRequestWithBody(this.endpoints.refreshToken, undefined, undefined);
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.status === 401) {
|
||||
return Err([new Error('Refresh and/or access token is not valid')]);
|
||||
}
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Refreshing token failed')]);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
return Ok(body as LoginResponse);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Refreshing token failed.')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class BaseAuthRequest {
|
||||
readonly username: string;
|
||||
readonly password: string;
|
||||
|
||||
constructor(username: string, password: string) {
|
||||
this.username = username;
|
||||
this.password = password;
|
||||
}
|
||||
}
|
||||
|
||||
class LoginRequest extends BaseAuthRequest {
|
||||
constructor(username: string, password: string) {
|
||||
super(username, password);
|
||||
}
|
||||
}
|
||||
|
||||
type LoginResponse = {
|
||||
accessToken: string;
|
||||
};
|
||||
@@ -0,0 +1,151 @@
|
||||
import type { InjectionKey } from 'vue';
|
||||
|
||||
type ClientKeyType = InjectionKey<IClientFactory>;
|
||||
|
||||
export const ClientFactoryKey: ClientKeyType = Symbol('AuthServiceFactory');
|
||||
|
||||
export interface IClientFactory {
|
||||
create: (config?: ClientConfig) => IClient;
|
||||
}
|
||||
|
||||
export class ClientFactory implements IClientFactory {
|
||||
create(config?: ClientConfig): IClient {
|
||||
return new Client(config);
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientRequest {
|
||||
readonly url: string;
|
||||
readonly config?: RequestInit;
|
||||
|
||||
constructor(url: string, config?: RequestInit) {
|
||||
this.url = url;
|
||||
this.config = config;
|
||||
}
|
||||
}
|
||||
|
||||
export class ClientRequestWithBody<T> extends ClientRequest {
|
||||
body: T | undefined;
|
||||
|
||||
constructor(url: string, config?: RequestInit, body?: T) {
|
||||
super(url, config);
|
||||
this.body = body;
|
||||
}
|
||||
}
|
||||
|
||||
export type UnauthorizedResponseHandler =
|
||||
| ((originalRequest: Request) => Promise<{ response: Response; accessToken: string }>)
|
||||
| undefined;
|
||||
|
||||
export class ClientConfig {
|
||||
authHeader: Record<string, string> | undefined;
|
||||
includeCredentials: boolean | undefined;
|
||||
unauthorizedResponseHandler: UnauthorizedResponseHandler;
|
||||
|
||||
constructor(
|
||||
authHeader?: Record<string, string> | undefined,
|
||||
includeCredentials?: boolean | undefined,
|
||||
unauthorizedResponseHandler?: UnauthorizedResponseHandler
|
||||
) {
|
||||
this.authHeader = authHeader;
|
||||
this.includeCredentials = includeCredentials;
|
||||
this.unauthorizedResponseHandler = unauthorizedResponseHandler;
|
||||
}
|
||||
}
|
||||
|
||||
export interface IClient {
|
||||
get: (req: ClientRequest) => Promise<Response>;
|
||||
post: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
|
||||
put: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
|
||||
patch: <T>(req: ClientRequestWithBody<T>) => Promise<Response>;
|
||||
delete: (req: ClientRequest) => Promise<Response>;
|
||||
}
|
||||
|
||||
export class Client implements IClient {
|
||||
private readonly _clientConfig: ClientConfig;
|
||||
|
||||
constructor(clientConfig?: ClientConfig) {
|
||||
this._clientConfig = clientConfig ?? new ClientConfig(undefined, true, undefined);
|
||||
}
|
||||
|
||||
private async request(url: string, config?: RequestInit): Promise<Response> {
|
||||
const headers = {
|
||||
...config?.headers,
|
||||
...this._clientConfig?.authHeader,
|
||||
};
|
||||
|
||||
const credentials = this._clientConfig?.includeCredentials
|
||||
? ('include' as RequestCredentials)
|
||||
: ('omit' as RequestCredentials);
|
||||
|
||||
const requestConfig = {
|
||||
...config,
|
||||
headers: headers,
|
||||
credentials: credentials,
|
||||
};
|
||||
|
||||
const firstTryRequest = new Request(url, requestConfig);
|
||||
const secondTryRequest = new Request(url, requestConfig);
|
||||
let response = await fetch(firstTryRequest);
|
||||
|
||||
if (response.status === 401 && this._clientConfig?.unauthorizedResponseHandler) {
|
||||
const retryResult = await this._clientConfig.unauthorizedResponseHandler(secondTryRequest);
|
||||
this._clientConfig.authHeader = { Authorization: `Bearer ${retryResult.accessToken}` };
|
||||
response = retryResult.response;
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
async get({ url, config }: { url: string; config?: RequestInit }) {
|
||||
const requestConfig = { ...config, method: 'GET' };
|
||||
return await this.request(url, requestConfig);
|
||||
}
|
||||
|
||||
async post<T>(req: ClientRequestWithBody<T>) {
|
||||
const requestConfig = {
|
||||
...req?.config,
|
||||
method: 'POST',
|
||||
body: JSON.stringify(req?.body),
|
||||
headers: {
|
||||
...req?.config?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
|
||||
async put<T>(req: ClientRequestWithBody<T>) {
|
||||
const requestConfig = {
|
||||
...req?.config,
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(req?.body),
|
||||
headers: {
|
||||
...req?.config?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
|
||||
async patch<T>(req: ClientRequestWithBody<T>) {
|
||||
const requestConfig = {
|
||||
...req?.config,
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(req?.body),
|
||||
headers: {
|
||||
...req?.config?.headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
|
||||
async delete(req: ClientRequest) {
|
||||
const requestConfig = { ...req?.config, method: 'DELETE' };
|
||||
return await this.request(req?.url, requestConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Err, Ok, Result } from 'ts-results';
|
||||
import { type InjectionKey } from 'vue';
|
||||
import { ClientRequest, ClientRequestWithBody, type IClient } from './client';
|
||||
|
||||
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
|
||||
|
||||
export const InstituionServiceFactoryKey: InstitutionServiceFactoryKeyType =
|
||||
Symbol('AuthServiceFactory');
|
||||
|
||||
export interface IInstitutionServiceFactory {
|
||||
create: (client: IClient) => IInstitutionService;
|
||||
}
|
||||
|
||||
export class InstitutionServiceFactory implements InstitutionServiceFactory {
|
||||
create(client: IClient): IInstitutionService {
|
||||
return new InstitutionService(client);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IInstitutionService {
|
||||
createLinkToken: () => Promise<Result<LinkTokenResponse, Error[]>>;
|
||||
connect: (publicToken: string, plaidInstitutionId: string) => Promise<Result<boolean, Error[]>>;
|
||||
getInstitutions: () => Promise<Result<Institution[], Error[]>>;
|
||||
}
|
||||
|
||||
export class InstitutionService implements IInstitutionService {
|
||||
private readonly client: IClient;
|
||||
private readonly endpoints = {
|
||||
link: '/api/institutions/link',
|
||||
connect: '/api/institutions/connect',
|
||||
institutions: '/api/institutions',
|
||||
};
|
||||
|
||||
constructor(client: IClient) {
|
||||
this.client = client;
|
||||
}
|
||||
|
||||
async createLinkToken() {
|
||||
const request = new ClientRequestWithBody(this.endpoints.link);
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Failed to create link token')]);
|
||||
}
|
||||
|
||||
const body = await res.json();
|
||||
return Ok(body as LinkTokenResponse);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Failed to create link token')]);
|
||||
}
|
||||
}
|
||||
|
||||
async connect(publicToken: string, plaidInstitutionId: string) {
|
||||
const request = new ClientRequestWithBody(this.endpoints.connect, undefined, {
|
||||
publicToken,
|
||||
plaidInstitutionId,
|
||||
});
|
||||
|
||||
try {
|
||||
const res = await this.client.post(request);
|
||||
|
||||
if (res.status === 400) {
|
||||
const body = await res.json();
|
||||
const validationErrors = body.errors as Record<string, string[]>;
|
||||
const errors = Object.values(validationErrors)
|
||||
.flat()
|
||||
.map(e => new Error(e));
|
||||
|
||||
return Err(errors);
|
||||
}
|
||||
|
||||
if (res.status === 401) {
|
||||
return Err([new Error('Please sign in and try again.')]);
|
||||
}
|
||||
|
||||
if (res.status === 409) {
|
||||
return Err([new Error('You have already linked this institution.')]);
|
||||
}
|
||||
|
||||
return Ok(true);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Failed to connect institution')]);
|
||||
}
|
||||
}
|
||||
|
||||
async getInstitutions() {
|
||||
const request = new ClientRequest(this.endpoints.institutions);
|
||||
|
||||
try {
|
||||
const res = await this.client.get(request);
|
||||
|
||||
if (res.ok === false) {
|
||||
return Err([new Error('Failed to get institutions')]);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
return Ok(data.institutions as Institution[]);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return Err([new Error('Failed to get institutions')]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type LinkTokenResponse = {
|
||||
linkToken: string;
|
||||
};
|
||||
|
||||
export type Institution = {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
import { AuthServiceFactoryKey } from '@/services/authService';
|
||||
import { ClientConfig, ClientFactoryKey } from '@/services/client';
|
||||
import { jwtDecode } from 'jwt-decode';
|
||||
import { defineStore } from 'pinia';
|
||||
import { inject, ref } from 'vue';
|
||||
|
||||
export const USER_KEY = 'fiscalos_auth';
|
||||
|
||||
export type User = {
|
||||
id: string;
|
||||
expiresAtInSeconds: number;
|
||||
token: string;
|
||||
sidebarCollapsed: boolean;
|
||||
};
|
||||
|
||||
type JwtTokenPayload = {
|
||||
sub: string;
|
||||
exp: number;
|
||||
};
|
||||
|
||||
function getUserFromLocalStorage(): User | null {
|
||||
const user = localStorage.getItem(USER_KEY);
|
||||
return user === null ? null : JSON.parse(user);
|
||||
}
|
||||
|
||||
function saveUserToLocalSotrage(user: User) {
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(user));
|
||||
}
|
||||
|
||||
export type UserStore = ReturnType<typeof useUserStore>;
|
||||
|
||||
export const useUserStore = defineStore('userStore', () => {
|
||||
const user = ref<User | null>(getUserFromLocalStorage());
|
||||
const clientFactory = inject(ClientFactoryKey);
|
||||
const authServiceFactory = inject(AuthServiceFactoryKey);
|
||||
|
||||
function logUserIn(jwtToken: string) {
|
||||
const { sub, exp } = jwtDecode<JwtTokenPayload>(jwtToken);
|
||||
const loggedInUser: User = {
|
||||
id: sub,
|
||||
expiresAtInSeconds: exp,
|
||||
token: jwtToken,
|
||||
sidebarCollapsed: false,
|
||||
};
|
||||
user.value = loggedInUser;
|
||||
saveUserToLocalSotrage(loggedInUser);
|
||||
}
|
||||
|
||||
function logUserOut() {
|
||||
localStorage.removeItem(USER_KEY);
|
||||
user.value = null;
|
||||
}
|
||||
|
||||
async function refreshAccessToken(originalRequest: Request) {
|
||||
const unauthorizedResponse = new Response(null, { status: 401 });
|
||||
|
||||
if (user.value === null || !clientFactory || !authServiceFactory) {
|
||||
logUserOut();
|
||||
return { response: unauthorizedResponse, accessToken: '' };
|
||||
}
|
||||
|
||||
const client = clientFactory.create(
|
||||
new ClientConfig({ Authorization: `Bearer ${user.value.token}` }, true)
|
||||
);
|
||||
|
||||
const authService = authServiceFactory.create(client);
|
||||
|
||||
const refreshResult = await authService.refreshToken();
|
||||
|
||||
if (refreshResult.err) {
|
||||
logUserOut();
|
||||
return { response: unauthorizedResponse, accessToken: '' };
|
||||
}
|
||||
|
||||
logUserIn(refreshResult.val.accessToken);
|
||||
|
||||
originalRequest.headers.set('Authorization', `Bearer ${refreshResult.val.accessToken}`);
|
||||
const response = await fetch(originalRequest);
|
||||
|
||||
return { response, accessToken: refreshResult.val.accessToken };
|
||||
}
|
||||
|
||||
async function toggleSidebar() {
|
||||
if (user.value === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updatedUser = {
|
||||
...user.value,
|
||||
sidebarCollapsed: !user.value?.sidebarCollapsed,
|
||||
}
|
||||
user.value = updatedUser;
|
||||
saveUserToLocalSotrage(updatedUser);
|
||||
}
|
||||
|
||||
return {
|
||||
user: user,
|
||||
logUserIn,
|
||||
logUserOut,
|
||||
refreshAccessToken,
|
||||
toggleSidebar,
|
||||
};
|
||||
});
|
||||
@@ -0,0 +1,156 @@
|
||||
<script setup lang="ts">
|
||||
import { useInstitutionService } from '@/composables/useInstitutionService';
|
||||
import type { Institution } from '@/services/institutionService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
|
||||
import { nextTick, ref, watch } from 'vue';
|
||||
|
||||
const userStore = useUserStore();
|
||||
const institutionService = useInstitutionService(userStore);
|
||||
|
||||
type InstitutionData =
|
||||
| {
|
||||
status: 'loading';
|
||||
}
|
||||
| { status: 'loaded'; data: Institution[] }
|
||||
| { status: 'errored'; errors: Error[] };
|
||||
|
||||
const institutionsData = ref<InstitutionData>({ status: 'loading' });
|
||||
|
||||
const plaidOptions = ref<PlaidLinkOptions>({
|
||||
token: '',
|
||||
onSuccess: async (publicToken, metadata) => {
|
||||
if (metadata.institution == null) {
|
||||
alert('Institution information is missing from Plaid response. Please try again.');
|
||||
return;
|
||||
}
|
||||
|
||||
const connectResult = await institutionService.connect(
|
||||
publicToken,
|
||||
metadata.institution.institution_id
|
||||
);
|
||||
|
||||
if (connectResult.err) {
|
||||
alert(connectResult.val.map(e => e.message).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
institutionsData.value = { status: 'loading' };
|
||||
},
|
||||
onLoad: () => console.log('loaded'),
|
||||
onExit: () => console.log('exit'),
|
||||
});
|
||||
const { open } = usePlaidLink(plaidOptions);
|
||||
|
||||
watch(
|
||||
institutionsData,
|
||||
async data => {
|
||||
if (data.status !== 'loading') {
|
||||
return;
|
||||
}
|
||||
|
||||
const institutionsResult = await institutionService.getInstitutions();
|
||||
|
||||
if (institutionsResult.err) {
|
||||
institutionsData.value = {
|
||||
status: 'errored',
|
||||
errors: institutionsResult.val,
|
||||
};
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
institutionsData.value = {
|
||||
status: 'loaded',
|
||||
data: institutionsResult.val,
|
||||
};
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
async function handleAddInstitutionClick() {
|
||||
const linkTokenResult = await institutionService.createLinkToken();
|
||||
|
||||
if (linkTokenResult.err) {
|
||||
alert(linkTokenResult.val.map(e => e.message).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
plaidOptions.value = {
|
||||
...plaidOptions.value,
|
||||
token: linkTokenResult.val.linkToken,
|
||||
};
|
||||
|
||||
await nextTick();
|
||||
|
||||
open();
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<button
|
||||
class="add-institution-button"
|
||||
type="button"
|
||||
@click="handleAddInstitutionClick"
|
||||
>
|
||||
Add Institution
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="institutionsData.status === 'loaded'"
|
||||
class="institutions-container"
|
||||
>
|
||||
<div
|
||||
class="institution-card"
|
||||
v-for="institution in institutionsData.data"
|
||||
v-bind:key="institution.id"
|
||||
>
|
||||
<div>
|
||||
<div>{{ institution.name }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<button
|
||||
class="add-account-button"
|
||||
type="button"
|
||||
>
|
||||
Add Account
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="institutionsData.status === 'errored'">Failed to load institutions</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.institutions-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.institution-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 1rem;
|
||||
border-radius: 0.25rem;
|
||||
background: var(--bg-surface);
|
||||
}
|
||||
|
||||
.institution-card > div {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.institution-card > div:last-of-type {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.add-institution-button,
|
||||
.add-account-button {
|
||||
background: var(--bg-element);
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import LoginForm, { type LoginFormState } from '@/components/LoginForm.vue';
|
||||
import { useAuthService } from '@/composables/useAuthService';
|
||||
import { useUserStore } from '@/stores/userStore';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const router = useRouter();
|
||||
const userStore = useUserStore();
|
||||
const authService = useAuthService(userStore);
|
||||
|
||||
async function handleValideSubmit(formState: LoginFormState) {
|
||||
const loginResult = await authService.login(formState.username.value, formState.password.value);
|
||||
|
||||
if (loginResult.err) {
|
||||
alert(loginResult.val.map(e => e.message).join('\n'));
|
||||
return;
|
||||
}
|
||||
|
||||
userStore.logUserIn(loginResult.val.accessToken);
|
||||
router.push('/');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoginForm :onValidSubmit="handleValideSubmit" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./tsconfig.node.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./tsconfig.vitest.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": "@tsconfig/node24/tsconfig.json",
|
||||
"include": [
|
||||
"vite.config.*",
|
||||
"vitest.config.*",
|
||||
"cypress.config.*",
|
||||
"nightwatch.conf.*",
|
||||
"playwright.config.*",
|
||||
"eslint.config.*"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"noEmit": true,
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"types": ["node"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "./tsconfig.app.json",
|
||||
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||
"exclude": [],
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo",
|
||||
|
||||
"lib": [],
|
||||
"types": ["node", "jsdom"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
import { defineConfig } from 'vite'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
import vueJsx from '@vitejs/plugin-vue-jsx'
|
||||
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||
import mkcert from 'vite-plugin-mkcert';
|
||||
|
||||
export default defineConfig({
|
||||
publicDir: 'static',
|
||||
plugins: [
|
||||
vue(),
|
||||
vueJsx(),
|
||||
vueDevTools(),
|
||||
mkcert(),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
port: parseInt(process.env.PORT ?? '5173'),
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.services__api__https__0 || process.env.services__api__http__0,
|
||||
changeOrigin: true,
|
||||
rewrite: path => path.replace(/^\/api/, ''),
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,14 @@
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||
import viteConfig from './vite.config'
|
||||
|
||||
export default mergeConfig(
|
||||
viteConfig,
|
||||
defineConfig({
|
||||
test: {
|
||||
environment: 'jsdom',
|
||||
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||
},
|
||||
}),
|
||||
)
|
||||
@@ -3,9 +3,12 @@
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Aspire.Hosting.Testing" Version="13.1.1" />
|
||||
<PackageVersion Include="AwesomeAssertions" Version="9.3.0" />
|
||||
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.2" />
|
||||
<PackageVersion Include="Microsoft.Playwright.Xunit.v3" Version="1.58.0" />
|
||||
<PackageVersion Include="Microsoft.Testing.Extensions.CodeCoverage" Version="18.3.2" />
|
||||
<PackageVersion Include="Microsoft.Testing.Extensions.VSTestBridge" Version="2.1.0" />
|
||||
<PackageVersion Include="Moq" Version="4.20.72" />
|
||||
<PackageVersion Include="xunit.v3.mtp-v2" Version="3.2.2" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -21,12 +21,6 @@ internal sealed class HttpResponseMessageAssertions(
|
||||
HttpStatusCode expectedStatusCode
|
||||
)
|
||||
{
|
||||
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/json")
|
||||
.FailWith(
|
||||
"Expected response to be application/json, but found {0}",
|
||||
Subject.Content.Headers.ContentType?.MediaType
|
||||
);
|
||||
|
||||
_chain.ForCondition(Subject.StatusCode == expectedStatusCode)
|
||||
.FailWith(
|
||||
"Expected response status code to be {0}, but found {1}",
|
||||
@@ -34,6 +28,13 @@ internal sealed class HttpResponseMessageAssertions(
|
||||
Subject.StatusCode
|
||||
);
|
||||
|
||||
_chain.ForCondition(Subject.Content.Headers.ContentType?.MediaType is "application/json")
|
||||
.FailWith(
|
||||
"Expected response to be application/json, but found {0}",
|
||||
Subject.Content.Headers.ContentType?.MediaType
|
||||
);
|
||||
|
||||
|
||||
var content = await Subject.Content.ReadFromJsonAsync<T>();
|
||||
|
||||
_chain.ForCondition(content is not null)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
namespace FiscalOS.API.Tests.Infra;
|
||||
|
||||
internal sealed class HttpRequestBuilder
|
||||
{
|
||||
private HttpMethod _method = HttpMethod.Get;
|
||||
private Uri? _uri;
|
||||
private object? _body;
|
||||
private string? _bearerToken;
|
||||
private readonly Dictionary<string, string> _cookies = [];
|
||||
private readonly Dictionary<string, string> _headers = [];
|
||||
|
||||
private HttpRequestBuilder()
|
||||
{
|
||||
}
|
||||
|
||||
public static HttpRequestBuilder New() => new();
|
||||
|
||||
public HttpRequestBuilder WithMethod(HttpMethod method)
|
||||
{
|
||||
_method = method;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithUri(Uri uri)
|
||||
{
|
||||
_uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithBody<T>(T body)
|
||||
{
|
||||
_body = body;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithBearerToken(string token)
|
||||
{
|
||||
_bearerToken = token;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithUserId(Guid userId)
|
||||
{
|
||||
_bearerToken = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, userId.ToString())
|
||||
.Build();
|
||||
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithCookie(string name, string value)
|
||||
{
|
||||
_cookies[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithRefreshCookie(string token)
|
||||
{
|
||||
return WithCookie("fiscalos_refresh_cookie", token);
|
||||
}
|
||||
|
||||
public HttpRequestBuilder WithHeader(string name, string value)
|
||||
{
|
||||
_headers[name] = value;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder Post(Uri uri)
|
||||
{
|
||||
_method = HttpMethod.Post;
|
||||
_uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder Get(Uri uri)
|
||||
{
|
||||
_method = HttpMethod.Get;
|
||||
_uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder Put(Uri uri)
|
||||
{
|
||||
_method = HttpMethod.Put;
|
||||
_uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestBuilder Delete(Uri uri)
|
||||
{
|
||||
_method = HttpMethod.Delete;
|
||||
_uri = uri;
|
||||
return this;
|
||||
}
|
||||
|
||||
public HttpRequestMessage Build()
|
||||
{
|
||||
if (_uri is null)
|
||||
{
|
||||
throw new InvalidOperationException("URI must be set before building the request.");
|
||||
}
|
||||
|
||||
var request = new HttpRequestMessage(_method, _uri);
|
||||
|
||||
if (_body is not null)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_body);
|
||||
request.Content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
}
|
||||
|
||||
if (_bearerToken is not null)
|
||||
{
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", _bearerToken);
|
||||
}
|
||||
|
||||
foreach (var (key, value) in _cookies)
|
||||
{
|
||||
request.Headers.Add("Cookie", $"{key}={value}");
|
||||
}
|
||||
|
||||
foreach (var (key, value) in _headers)
|
||||
{
|
||||
request.Headers.Add(key, value);
|
||||
}
|
||||
|
||||
return request;
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
namespace FiscalOS.API.Tests.Common;
|
||||
namespace FiscalOS.API.Tests.Infra;
|
||||
|
||||
public interface ISerializableDictionary<TKey, TValue> : IDictionary<TKey, TValue>, IXunitSerializable
|
||||
{
|
||||
}
|
||||
|
||||
public class SerializableDictionary<TKey, TValue>
|
||||
public class SerializableDictionary<TKey, TValue>
|
||||
: Dictionary<TKey, TValue>, ISerializableDictionary<TKey, TValue> where TKey : notnull
|
||||
{
|
||||
public SerializableDictionary() { }
|
||||
@@ -1,5 +1,3 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace FiscalOS.API.Tests.Infra;
|
||||
|
||||
public class TestApi : WebApplicationFactory<Program>
|
||||
|
||||
+90
-114
@@ -1,7 +1,7 @@
|
||||
using Account = FiscalOS.Core.Accounts.Account;
|
||||
using Institution = FiscalOS.Core.Accounts.Institution;
|
||||
|
||||
namespace FiscalOS.API.Tests.Integration;
|
||||
namespace FiscalOS.API.Tests.Integration.Accounts;
|
||||
|
||||
public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
@@ -16,19 +16,14 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithoutInstitutionIdOrAccountIdOrAccountName_ItShouldReturn400WithProblemDetails()
|
||||
public async Task Add_WhenCalledWithoutRequiredInformation_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new { })
|
||||
.Build();
|
||||
|
||||
using var content = new StringContent(JsonSerializer.Serialize(new { }), Encoding.UTF8, "application/json");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
@@ -36,28 +31,24 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
["PlaidInstitutionId"] = ["The PlaidInstitutionId field is required."],
|
||||
["PlaidAccountId"] = ["The PlaidAccountId field is required."],
|
||||
["PlaidAccountName"] = ["The PlaidAccountName field is required."],
|
||||
["AccountCurrencyCode"] = ["The AccountCurrencyCode field is required."],
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithoutInstitutionId_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidAccountId = "accountId",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
plaidAccountId = "accountId",
|
||||
plaidAccountName = "Some Account",
|
||||
});
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
@@ -69,22 +60,17 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithoutAccountId_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "institutionId",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
plaidInstitutionId = "institutionId",
|
||||
plaidAccountName = "Some Account",
|
||||
});
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
@@ -96,22 +82,17 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithoutAccountName_ItShouldReturn400WithProblemDetails()
|
||||
{
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "institutionId",
|
||||
plaidAccountId = "accountId",
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
plaidInstitutionId = "institutionId",
|
||||
plaidAccountId = "accountId",
|
||||
});
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
|
||||
{
|
||||
Content = content
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
@@ -123,23 +104,18 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Add_WhenCalledWithNonExistentUser_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, Guid.NewGuid().ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(Guid.NewGuid())
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "id",
|
||||
plaidAccountId = "id",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
plaidInstitutionId = "id",
|
||||
plaidAccountId = "id",
|
||||
plaidAccountName = "Some Account",
|
||||
});
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
@@ -162,23 +138,18 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
return user;
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = "id",
|
||||
plaidAccountId = "id",
|
||||
plaidAccountName = "Some Account",
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
plaidInstitutionId = "id",
|
||||
plaidAccountId = "id",
|
||||
plaidAccountName = "Some Account",
|
||||
});
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeValidationProblemDetails(new Dictionary<string, string[]>()
|
||||
@@ -216,23 +187,18 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
return (user, institution, account);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId,
|
||||
plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
|
||||
plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
|
||||
accountCurrencyCode = "USD",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId,
|
||||
plaidAccountId = ((PlaidAccountMetadata)account.Metadata!).PlaidId,
|
||||
plaidAccountName = ((PlaidAccountMetadata)account.Metadata).PlaidName,
|
||||
});
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await response.Should().BeProblemDetails(HttpStatusCode.Conflict);
|
||||
@@ -261,25 +227,24 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
return (user, institution);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var jwt = JwtTokenBuilder.New()
|
||||
.WithClaim(JwtRegisteredClaimNames.Sub, user.Id.ToString())
|
||||
.Build();
|
||||
|
||||
var newAccountId = "newAccountId";
|
||||
var newAccountName = "New Account";
|
||||
var expectedBalance = 100;
|
||||
var expectedCurrencyCode = "USD";
|
||||
|
||||
var json = JsonSerializer.Serialize(new
|
||||
{
|
||||
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId,
|
||||
plaidAccountId = newAccountId,
|
||||
plaidAccountName = newAccountName,
|
||||
});
|
||||
using var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, AddUri)
|
||||
{
|
||||
Content = content,
|
||||
};
|
||||
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", jwt);
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(AddUri)
|
||||
.WithUserId(user.Id)
|
||||
.WithBody(new
|
||||
{
|
||||
plaidInstitutionId = ((PlaidMetadata)institution.Metadata!).PlaidId,
|
||||
plaidAccountId = newAccountId,
|
||||
plaidAccountName = newAccountName,
|
||||
accountCurrentBalance = expectedBalance,
|
||||
accountAvailableBalance = expectedBalance,
|
||||
accountCurrencyCode = expectedCurrencyCode,
|
||||
})
|
||||
.Build();
|
||||
|
||||
var response = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
@@ -289,6 +254,9 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
async (context, ct) => await context.Set<User>()
|
||||
.Include(u => u.Accounts)
|
||||
.ThenInclude(a => a.Metadata)
|
||||
.Include(u => u.Accounts)
|
||||
.ThenInclude(a => a.Balances)
|
||||
.AsSplitQuery()
|
||||
.FirstAsync(u => u.Id == user.Id, ct),
|
||||
TestContext.Current.CancellationToken
|
||||
);
|
||||
@@ -300,5 +268,13 @@ public class AddTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
((PlaidAccountMetadata)a.Metadata).PlaidId == newAccountId &&
|
||||
((PlaidAccountMetadata)a.Metadata).PlaidName == newAccountName
|
||||
);
|
||||
|
||||
updatedUser.Accounts.First()
|
||||
.Balances.Should().ContainSingle(
|
||||
b => b.AccountId == updatedUser.Accounts.First().Id &&
|
||||
b.Current == expectedBalance &&
|
||||
b.Available == expectedBalance &&
|
||||
b.CurrencyCode == expectedCurrencyCode
|
||||
);
|
||||
}
|
||||
}
|
||||
+39
-29
@@ -1,22 +1,23 @@
|
||||
using FiscalOS.API.Tests.Common;
|
||||
|
||||
namespace FiscalOS.API.Tests.Integration;
|
||||
namespace FiscalOS.API.Tests.Integration.Auth;
|
||||
|
||||
public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
{
|
||||
private static readonly Uri LoginUri = new("/login", UriKind.Relative);
|
||||
private static readonly Uri LoginUri = new("/auth/login", UriKind.Relative);
|
||||
|
||||
[Theory]
|
||||
[ClassData<LoginValidationTestCases>]
|
||||
public async Task Login_WhenUserSubmitsInvalidRequest_ItShouldReturn400WithProblemDetails(LoginValidationTestCase tc)
|
||||
{
|
||||
var req = new
|
||||
{
|
||||
username = tc.Username,
|
||||
password = tc.Password,
|
||||
};
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = tc.Username,
|
||||
password = tc.Password,
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeValidationProblemDetails(tc.ExpectedErrors);
|
||||
}
|
||||
@@ -24,13 +25,16 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
[Fact]
|
||||
public async Task Login_WhenUserDoesNotExist_ItShouldReturn401WithProblemDetails()
|
||||
{
|
||||
var req = new
|
||||
{
|
||||
username = "Test",
|
||||
password = "@Password2",
|
||||
};
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = "Test",
|
||||
password = "@Password2",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
@@ -49,13 +53,16 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
await context.SaveChangesAsync(ct);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var req = new
|
||||
{
|
||||
username = "Stevan",
|
||||
password = "@Password2",
|
||||
};
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = "Stevan",
|
||||
password = "@Password2",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||
}
|
||||
@@ -74,16 +81,19 @@ public class LoginTests(TestApi testApi) : IntegrationTest(testApi)
|
||||
await context.SaveChangesAsync(ct);
|
||||
}, TestContext.Current.CancellationToken);
|
||||
|
||||
var req = new
|
||||
{
|
||||
username = "Stevan",
|
||||
password = "@Password1",
|
||||
};
|
||||
using var request = HttpRequestBuilder.New()
|
||||
.Post(LoginUri)
|
||||
.WithBody(new
|
||||
{
|
||||
username = "Stevan",
|
||||
password = "@Password1",
|
||||
})
|
||||
.Build();
|
||||
|
||||
var res = await Client.PostAsJsonAsync(LoginUri, req, TestContext.Current.CancellationToken);
|
||||
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||
|
||||
res.Should().HaveSetCookieHeader("fiscalos_refresh_cookie");
|
||||
await res.Should().BeJsonContentOfType<Login.Response>(HttpStatusCode.OK);
|
||||
await res.Should().BeJsonContentOfType<API.Auth.Login.Response>(HttpStatusCode.OK);
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user