Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba451e2b23 |
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ internal static class InstitutionsExtensions
|
|||||||
institutionsGroup.MapConnectEndpoint();
|
institutionsGroup.MapConnectEndpoint();
|
||||||
institutionsGroup.MapGetAvailableEndpoint();
|
institutionsGroup.MapGetAvailableEndpoint();
|
||||||
institutionsGroup.MapLinkEndpoint();
|
institutionsGroup.MapLinkEndpoint();
|
||||||
|
institutionsGroup.MapGetEndpoint();
|
||||||
|
|
||||||
return institutionsGroup;
|
return institutionsGroup;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ global using System.ComponentModel.DataAnnotations;
|
|||||||
global using System.Security.Claims;
|
global using System.Security.Claims;
|
||||||
global using System.Text.Json.Serialization;
|
global using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
global using FiscalOS.API.Institutions.Get;
|
||||||
global using FiscalOS.API.Accounts;
|
global using FiscalOS.API.Accounts;
|
||||||
global using FiscalOS.API.Accounts.Add;
|
global using FiscalOS.API.Accounts.Add;
|
||||||
global using FiscalOS.API.Auth;
|
global using FiscalOS.API.Auth;
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ button {
|
|||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-family: inherit;
|
font-family: inherit;
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
color: inherit;
|
||||||
}
|
}
|
||||||
|
|
||||||
input {
|
input {
|
||||||
|
|||||||
@@ -1,14 +1,28 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
|
import LeftArrowIcon from '@/components/icons/RightArrowIcon.vue';
|
||||||
import { computed, ref } from 'vue';
|
import { useUserStore } from '@/stores/userStore';
|
||||||
|
import { computed } from 'vue';
|
||||||
|
import { useRouter } from 'vue-router';
|
||||||
|
|
||||||
|
const userStore = useUserStore();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
const isCollapsed = ref(false);
|
|
||||||
const asideClasses = computed(() => ({
|
const asideClasses = computed(() => ({
|
||||||
collapsed: isCollapsed.value,
|
collapsed: userStore.user?.sidebarCollapsed,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
function handleToggleButtonClick() {
|
function handleToggleButtonClick() {
|
||||||
isCollapsed.value = !isCollapsed.value;
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -21,6 +35,13 @@
|
|||||||
>
|
>
|
||||||
<LeftArrowIcon />
|
<LeftArrowIcon />
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="logout-button"
|
||||||
|
type="button"
|
||||||
|
@click="handleLogout"
|
||||||
|
>
|
||||||
|
Logout
|
||||||
|
</button>
|
||||||
</aside>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -47,6 +68,12 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.logout-button {
|
||||||
|
background: var(--bg-element);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
.toggle-button {
|
.toggle-button {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -21,6 +21,6 @@
|
|||||||
|
|
||||||
main {
|
main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
border: 1px solid blue;
|
padding: 1rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Err, Ok, Result } from 'ts-results';
|
import { Err, Ok, Result } from 'ts-results';
|
||||||
import { type InjectionKey } from 'vue';
|
import { type InjectionKey } from 'vue';
|
||||||
import { ClientRequestWithBody, type IClient } from './client';
|
import { ClientRequest, ClientRequestWithBody, type IClient } from './client';
|
||||||
|
|
||||||
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
|
type InstitutionServiceFactoryKeyType = InjectionKey<IInstitutionServiceFactory>;
|
||||||
|
|
||||||
@@ -20,6 +20,7 @@ export class InstitutionServiceFactory implements InstitutionServiceFactory {
|
|||||||
export interface IInstitutionService {
|
export interface IInstitutionService {
|
||||||
createLinkToken: () => Promise<Result<LinkTokenResponse, Error[]>>;
|
createLinkToken: () => Promise<Result<LinkTokenResponse, Error[]>>;
|
||||||
connect: (publicToken: string, plaidInstitutionId: string) => Promise<Result<boolean, Error[]>>;
|
connect: (publicToken: string, plaidInstitutionId: string) => Promise<Result<boolean, Error[]>>;
|
||||||
|
getInstitutions: () => Promise<Result<Institution[], Error[]>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class InstitutionService implements IInstitutionService {
|
export class InstitutionService implements IInstitutionService {
|
||||||
@@ -27,6 +28,7 @@ export class InstitutionService implements IInstitutionService {
|
|||||||
private readonly endpoints = {
|
private readonly endpoints = {
|
||||||
link: '/api/institutions/link',
|
link: '/api/institutions/link',
|
||||||
connect: '/api/institutions/connect',
|
connect: '/api/institutions/connect',
|
||||||
|
institutions: '/api/institutions',
|
||||||
};
|
};
|
||||||
|
|
||||||
constructor(client: IClient) {
|
constructor(client: IClient) {
|
||||||
@@ -84,8 +86,32 @@ export class InstitutionService implements IInstitutionService {
|
|||||||
return Err([new Error('Failed to connect institution')]);
|
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 = {
|
type LinkTokenResponse = {
|
||||||
linkToken: string;
|
linkToken: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type Institution = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export type User = {
|
|||||||
id: string;
|
id: string;
|
||||||
expiresAtInSeconds: number;
|
expiresAtInSeconds: number;
|
||||||
token: string;
|
token: string;
|
||||||
|
sidebarCollapsed: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
type JwtTokenPayload = {
|
type JwtTokenPayload = {
|
||||||
@@ -22,6 +23,10 @@ function getUserFromLocalStorage(): User | null {
|
|||||||
return user === null ? null : JSON.parse(user);
|
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 type UserStore = ReturnType<typeof useUserStore>;
|
||||||
|
|
||||||
export const useUserStore = defineStore('userStore', () => {
|
export const useUserStore = defineStore('userStore', () => {
|
||||||
@@ -35,9 +40,10 @@ export const useUserStore = defineStore('userStore', () => {
|
|||||||
id: sub,
|
id: sub,
|
||||||
expiresAtInSeconds: exp,
|
expiresAtInSeconds: exp,
|
||||||
token: jwtToken,
|
token: jwtToken,
|
||||||
|
sidebarCollapsed: false,
|
||||||
};
|
};
|
||||||
localStorage.setItem(USER_KEY, JSON.stringify(loggedInUser));
|
|
||||||
user.value = loggedInUser;
|
user.value = loggedInUser;
|
||||||
|
saveUserToLocalSotrage(loggedInUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
function logUserOut() {
|
function logUserOut() {
|
||||||
@@ -74,10 +80,24 @@ export const useUserStore = defineStore('userStore', () => {
|
|||||||
return { response, accessToken: refreshResult.val.accessToken };
|
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 {
|
return {
|
||||||
user: user,
|
user: user,
|
||||||
logUserIn,
|
logUserIn,
|
||||||
logUserOut,
|
logUserOut,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
|
toggleSidebar,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,13 +1,22 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useInstitutionService } from '@/composables/useInstitutionService';
|
import { useInstitutionService } from '@/composables/useInstitutionService';
|
||||||
|
import type { Institution } from '@/services/institutionService';
|
||||||
import { useUserStore } from '@/stores/userStore';
|
import { useUserStore } from '@/stores/userStore';
|
||||||
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
|
import { usePlaidLink, type PlaidLinkOptions } from '@jcss/vue-plaid-link';
|
||||||
import { nextTick, ref } from 'vue';
|
import { nextTick, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
|
||||||
|
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const router = useRouter();
|
|
||||||
const institutionService = useInstitutionService(userStore);
|
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>({
|
const plaidOptions = ref<PlaidLinkOptions>({
|
||||||
token: '',
|
token: '',
|
||||||
onSuccess: async (publicToken, metadata) => {
|
onSuccess: async (publicToken, metadata) => {
|
||||||
@@ -22,25 +31,43 @@ const plaidOptions = ref<PlaidLinkOptions>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (connectResult.err) {
|
if (connectResult.err) {
|
||||||
alert(connectResult.val.map(e => e.message).join('\n'))
|
alert(connectResult.val.map(e => e.message).join('\n'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
institutionsData.value = { status: 'loading' };
|
||||||
},
|
},
|
||||||
onLoad: () => console.log('loaded'),
|
onLoad: () => console.log('loaded'),
|
||||||
onExit: () => console.log('exit'),
|
onExit: () => console.log('exit'),
|
||||||
});
|
});
|
||||||
const { open } = usePlaidLink(plaidOptions);
|
const { open } = usePlaidLink(plaidOptions);
|
||||||
|
|
||||||
function handleLogout() {
|
watch(
|
||||||
// TODO: Also need to log user
|
institutionsData,
|
||||||
// out on the server...which is
|
async data => {
|
||||||
// basically just clearing
|
if (data.status !== 'loading') {
|
||||||
// refresh token cookie and revoking
|
return;
|
||||||
// it in the database
|
|
||||||
userStore.logUserOut();
|
|
||||||
router.push({ path: '/public/login' });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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() {
|
async function handleAddInstitutionClick() {
|
||||||
const linkTokenResult = await institutionService.createLinkToken();
|
const linkTokenResult = await institutionService.createLinkToken();
|
||||||
|
|
||||||
@@ -61,20 +88,67 @@ async function handleAddInstitutionClick() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<h1>Home View</h1>
|
|
||||||
<button class="logout-button" type="button" @click="handleLogout">
|
|
||||||
Logout
|
|
||||||
</button>
|
|
||||||
<div>
|
<div>
|
||||||
<button class="add-institution-button" type="button" @click="handleAddInstitutionClick">
|
<button
|
||||||
|
class="add-institution-button"
|
||||||
|
type="button"
|
||||||
|
@click="handleAddInstitutionClick"
|
||||||
|
>
|
||||||
Add Institution
|
Add Institution
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.logout-button,
|
.institutions-container {
|
||||||
.add-institution-button {
|
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);
|
background: var(--bg-element);
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
border-radius: 0.25rem;
|
border-radius: 0.25rem;
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using FiscalOS.API.Institutions.Get;
|
||||||
|
|
||||||
|
using Institution = FiscalOS.Core.Accounts.Institution;
|
||||||
|
|
||||||
|
namespace FiscalOS.API.Tests.Integration.Institutions;
|
||||||
|
|
||||||
|
public class GetTests(TestApi testApi) : IntegrationTest(testApi)
|
||||||
|
{
|
||||||
|
private static readonly Uri GetUri = new("/institutions", UriKind.Relative);
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_WhenCalledWithoutValidToken_ItShouldReturn401WithProblemDetails()
|
||||||
|
{
|
||||||
|
using var request = HttpRequestBuilder.New()
|
||||||
|
.Get(GetUri)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_WhenCalledByNonExistentUser_ItShouldReturn401WithProblemDetails()
|
||||||
|
{
|
||||||
|
using var request = HttpRequestBuilder.New()
|
||||||
|
.Get(GetUri)
|
||||||
|
.WithUserId(Guid.NewGuid())
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
await res.Should().BeProblemDetails(HttpStatusCode.Unauthorized);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Get_WhenCalledByUser_ItShouldReturn200WithListOfInstitutions()
|
||||||
|
{
|
||||||
|
var (user, institution) = await ExecuteAsync(static async (context, ct, sp) =>
|
||||||
|
{
|
||||||
|
var passwordHasher = sp.GetRequiredService<IPasswordHasher>();
|
||||||
|
var encryptor = sp.GetRequiredService<IEncryptor>();
|
||||||
|
|
||||||
|
var userEncryptionKey = await encryptor.GenerateEncryptedKeyAsync(ct);
|
||||||
|
var user = User.From("User1", passwordHasher.Hash("@Password1"), userEncryptionKey);
|
||||||
|
|
||||||
|
var encryptedAccessToken = await encryptor.EncryptAsyncFor(user, "accessToken", ct);
|
||||||
|
var plaidMetadata = PlaidMetadata.From("alreadyExists", "Some Bank", encryptedAccessToken);
|
||||||
|
var institution = Institution.From("Some Bank", plaidMetadata);
|
||||||
|
|
||||||
|
user.AddInstitution(institution);
|
||||||
|
|
||||||
|
await context.AddAsync(user, ct);
|
||||||
|
await context.SaveChangesAsync(ct);
|
||||||
|
return (user, institution);
|
||||||
|
}, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
using var request = HttpRequestBuilder.New()
|
||||||
|
.Get(GetUri)
|
||||||
|
.WithUserId(user.Id)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var res = await Client.SendAsync(request, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
(await res.Should().BeJsonContentOfType<Response>(HttpStatusCode.OK))
|
||||||
|
.Which.Institutions.Should().BeEquivalentTo(
|
||||||
|
[
|
||||||
|
InstitutionDto.FromInstitution(institution),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user