Compare commits
15 Commits
e9b4a2bdc2
...
develop
| Author | SHA1 | Date | |
|---|---|---|---|
| 62a31d449b | |||
| 66a360c63f | |||
| 42a45aeead | |||
| e482f40f8e | |||
| fb10524ccc | |||
| 391fa7ed99 | |||
| c108ebd1e0 | |||
| 5794991b30 | |||
| 8c5b90a961 | |||
| 5781c245e3 | |||
| f48dd0240f | |||
| 9fa7ebbc26 | |||
| 8a57d43b20 | |||
| ecbd7bc575 | |||
| d4a0deacdb |
@@ -133,6 +133,8 @@ dotnet run --launch-profile swagger_dev
|
||||
|
||||
## Tests
|
||||
|
||||
**Status:** 76 .NET-Tests + 132 Angular-Tests — alle grün.
|
||||
|
||||
### Backend (xUnit)
|
||||
|
||||
```bash
|
||||
@@ -146,6 +148,8 @@ dotnet test tests/Application.UnitTest
|
||||
dotnet test tests/Application.FunctionalTest
|
||||
```
|
||||
|
||||
Getestete Komponenten: `Result`/`PagedResult` (Edge Cases), `JwtService` (Token-Generierung, Refresh-Validierung), `EmailService` (SMTP-Interaktion), `AuthenticationService`.
|
||||
|
||||
### Frontend (Karma + Jasmine)
|
||||
|
||||
```bash
|
||||
@@ -162,6 +166,13 @@ npm run test:coverage
|
||||
# Report: src/ClientApp/coverage/index.html
|
||||
```
|
||||
|
||||
```bash
|
||||
# Headless (benötigt Chromium-Pfad)
|
||||
CHROME_BIN=/usr/bin/chromium npx ng test --no-watch --browsers=ChromeHeadless
|
||||
```
|
||||
|
||||
Getestete Komponenten: `tokenInterceptor` (401-Refresh-Flow), `HeaderComponent` (Logout/Rolle/Username), `authService` (HTTP-Login/Register/Renew), `DarkModeService` (localStorage-Persistenz), `ToastComponent` (Auto-Dismiss), `PopupModalComponent` (Inputs/Outputs), `ForgetPasswordPopupComponent` (Close/Error/Success).
|
||||
|
||||
---
|
||||
|
||||
## NPM-Skripte (Frontend)
|
||||
@@ -215,19 +226,7 @@ Basis: `http://localhost:5184/api` (Dev) / `https://localhost:7091/api`
|
||||
Seed-Daten: SuperAdmin (ID 1) und Admin (ID 2) werden automatisch angelegt.
|
||||
|
||||
---
|
||||
|
||||
## Docker
|
||||
|
||||
Es ist kein Docker-Setup im Projekt enthalten. Für PostgreSQL kann ein Docker-Container gestartet werden:
|
||||
|
||||
```bash
|
||||
docker run -d --name postgres -e POSTGRES_PASSWORD=<passwort> -p 5432:5432 postgres:17
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Hinweise
|
||||
|
||||
- Der JWT-Secret-Key in `appsettings.json` ist ein Platzhalter und sollte in Produktion überschrieben werden (z. B. über User Secrets, Umgebungsvariablen oder `appsettings.Production.json`).
|
||||
- E-Mail-Versand verwendet Gmail SMTP – es wird ein [App-Passwort](https://support.google.com/accounts/answer/185833) benötigt.
|
||||
- Der ClientApp-README (generiert von Angular CLI) wurde auf die tatsächliche Angular-Version aktualisiert.
|
||||
|
||||
+5
-5
@@ -11,11 +11,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="10.0.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="10.1.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.1.7" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.1.7" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.SpaProxy" Version="10.0.9" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.Swagger" Version="10.2.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerGen" Version="10.2.1" />
|
||||
<PackageReference Include="Swashbuckle.AspNetCore.SwaggerUI" Version="10.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -13,12 +13,12 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MailKit" Version="4.16.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.7" />
|
||||
<PackageReference Include="MimeKit" Version="4.16.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.18.0" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.Extensions.Identity.Core" Version="10.0.9" />
|
||||
<PackageReference Include="MimeKit" Version="4.17.0" />
|
||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.19.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
Generated
+1681
-1198
File diff suppressed because it is too large
Load Diff
+15
-14
@@ -12,15 +12,15 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/animations": "^21.2.4",
|
||||
"@angular/common": "^21.2.4",
|
||||
"@angular/compiler": "^21.2.4",
|
||||
"@angular/core": "^21.2.4",
|
||||
"@angular/forms": "^21.2.4",
|
||||
"@angular/localize": "^21.2.4",
|
||||
"@angular/platform-browser": "^21.2.4",
|
||||
"@angular/platform-browser-dynamic": "^21.2.4",
|
||||
"@angular/router": "^21.2.4",
|
||||
"@angular/animations": "^22.0.1",
|
||||
"@angular/common": "^22.0.1",
|
||||
"@angular/compiler": "^22.0.1",
|
||||
"@angular/core": "^22.0.1",
|
||||
"@angular/forms": "^22.0.1",
|
||||
"@angular/localize": "^22.0.1",
|
||||
"@angular/platform-browser": "^22.0.1",
|
||||
"@angular/platform-browser-dynamic": "^22.0.1",
|
||||
"@angular/router": "^22.0.1",
|
||||
"@auth0/angular-jwt": "^5.2.0",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap-icons": "^1.13.1",
|
||||
@@ -29,10 +29,11 @@
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular/build": "^21.2.2",
|
||||
"@angular/cli": "^21.2.2",
|
||||
"@angular/compiler-cli": "^21.2.4",
|
||||
"@angular/build": "^22.0.0",
|
||||
"@angular/cli": "^22.0.0",
|
||||
"@angular/compiler-cli": "^22.0.1",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"istanbul-lib-instrument": "^6.0.3",
|
||||
"jasmine-core": "~5.2.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
@@ -41,6 +42,6 @@
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"puppeteer": "^24.43.0",
|
||||
"typescript": "~5.9.3"
|
||||
"typescript": "~6.0.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component, inject} from '@angular/core';
|
||||
import {Component, inject, ChangeDetectionStrategy} from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import {FooterComponent} from "./presentation/components/footer/footer.component";
|
||||
import {ToastComponent} from "./presentation/shared/toast/toast.component";
|
||||
@@ -10,6 +10,7 @@ import {DarkModeService} from "./infrastructure/services/dark-mode.service";
|
||||
imports: [RouterOutlet, FooterComponent, ToastComponent, HeaderComponent],
|
||||
templateUrl: './app.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
export class AppComponent {
|
||||
|
||||
@@ -4,10 +4,10 @@ import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
import {provideHttpClient, withFetch, withInterceptors} from "@angular/common/http";
|
||||
import {tokenInterceptor} from "./presentation/interceptors/token.interceptor";
|
||||
import {provideClientHydration, withEventReplay} from "@angular/platform-browser";
|
||||
import {provideClientHydration, withEventReplay, withNoIncrementalHydration} from "@angular/platform-browser";
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes),
|
||||
provideHttpClient(withFetch(), withInterceptors([tokenInterceptor
|
||||
])), provideClientHydration(withEventReplay())]
|
||||
])), provideClientHydration(withEventReplay(), withNoIncrementalHydration())]
|
||||
};
|
||||
|
||||
@@ -1,16 +1,27 @@
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {provideHttpClient} from '@angular/common/http';
|
||||
import {provideHttpClient, withXhr} from '@angular/common/http';
|
||||
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
|
||||
import {AuthService} from './auth-service';
|
||||
import {environment} from '../../../environments/environment.development';
|
||||
|
||||
describe('AuthService', () => {
|
||||
let service: AuthService;
|
||||
let httpTesting: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
TestBed.configureTestingModule({
|
||||
providers: [provideHttpClient()]
|
||||
providers: [
|
||||
provideHttpClient(withXhr()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
service = TestBed.inject(AuthService);
|
||||
httpTesting = TestBed.inject(HttpTestingController);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
httpTesting.verify();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
@@ -42,4 +53,42 @@ describe('AuthService', () => {
|
||||
service.signOut();
|
||||
expect(service.isLoggedIn()).toBeFalse();
|
||||
});
|
||||
|
||||
it('should POST to auth/login on login', () => {
|
||||
const credentials = {email: 'test@example.com', password: 'password123'};
|
||||
|
||||
service.login(credentials).subscribe();
|
||||
|
||||
const req = httpTesting.expectOne(`${environment.baseUrl}auth/login`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(credentials);
|
||||
req.flush({value: {token: {accessToken: 'access', refreshToken: 'refresh'}}});
|
||||
});
|
||||
|
||||
it('should POST to auth/register on register', () => {
|
||||
const request = {username: 'newuser', email: 'new@example.com', password: 'StrongP@ss1'};
|
||||
|
||||
service.register(request).subscribe();
|
||||
|
||||
const req = httpTesting.expectOne(`${environment.baseUrl}auth/register`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(request);
|
||||
req.flush({value: 'User registered successfully'});
|
||||
});
|
||||
|
||||
it('should POST to auth/refresh-token on renewToken', () => {
|
||||
const refreshRequest = {userId: 1, refreshToken: 'refresh-token'};
|
||||
|
||||
service.renewToken(refreshRequest).subscribe();
|
||||
|
||||
const req = httpTesting.expectOne(`${environment.baseUrl}auth/refresh-token`);
|
||||
expect(req.request.method).toBe('POST');
|
||||
expect(req.request.body).toEqual(refreshRequest);
|
||||
req.flush({value: {accessToken: 'new-access', refreshToken: 'new-refresh'}});
|
||||
});
|
||||
|
||||
it('should return null for decodedToken when no token', () => {
|
||||
localStorage.removeItem('token');
|
||||
expect(service.decodedToken()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,27 +1,66 @@
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {PLATFORM_ID} from '@angular/core';
|
||||
import {DarkModeService} from './dark-mode.service';
|
||||
|
||||
describe('DarkModeService', () => {
|
||||
let service: DarkModeService;
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({});
|
||||
service = TestBed.inject(DarkModeService);
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it('should be created', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
const service = TestBed.inject(DarkModeService);
|
||||
expect(service).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should toggle from null to dark', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
const service = TestBed.inject(DarkModeService);
|
||||
service.darkModeSignal.set(null);
|
||||
service.updateDarkMode();
|
||||
expect(service.darkModeSignal()).toBe('dark');
|
||||
});
|
||||
|
||||
it('should toggle from dark to null', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
const service = TestBed.inject(DarkModeService);
|
||||
service.darkModeSignal.set('dark');
|
||||
service.updateDarkMode();
|
||||
expect(service.darkModeSignal()).toBeNull();
|
||||
});
|
||||
|
||||
it('should start with null when localStorage is empty', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
const service = TestBed.inject(DarkModeService);
|
||||
expect(localStorage.getItem('darkModeSignal')).toBeNull();
|
||||
expect(service.darkModeSignal()).toBeNull();
|
||||
});
|
||||
|
||||
it('should read initial value from localStorage', () => {
|
||||
localStorage.setItem('darkModeSignal', '"dark"');
|
||||
TestBed.configureTestingModule({});
|
||||
const service = TestBed.inject(DarkModeService);
|
||||
TestBed.flushEffects();
|
||||
expect(service.darkModeSignal()).toBe('dark');
|
||||
});
|
||||
|
||||
it('should persist to localStorage when value changes', () => {
|
||||
TestBed.configureTestingModule({});
|
||||
const service = TestBed.inject(DarkModeService);
|
||||
service.darkModeSignal.set('dark');
|
||||
TestBed.flushEffects();
|
||||
expect(localStorage.getItem('darkModeSignal')).toBe('"dark"');
|
||||
service.darkModeSignal.set(null);
|
||||
TestBed.flushEffects();
|
||||
expect(localStorage.getItem('darkModeSignal')).toBe('null');
|
||||
});
|
||||
|
||||
it('should do nothing in non-browser platform', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [{provide: PLATFORM_ID, useValue: 'server'}]
|
||||
});
|
||||
const serverService = TestBed.inject(DarkModeService);
|
||||
serverService.updateDarkMode();
|
||||
expect(serverService.darkModeSignal()).toBe('dark');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {provideHttpClient} from '@angular/common/http';
|
||||
import {provideHttpClient, withXhr} from '@angular/common/http';
|
||||
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
|
||||
import {ResetPasswordService} from './reset-password.service';
|
||||
import {environment} from '../../../environments/environment.development';
|
||||
@@ -11,7 +11,7 @@ describe('ResetPasswordService', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClient(withXhr()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {provideHttpClient} from '@angular/common/http';
|
||||
import {provideHttpClient, withXhr} from '@angular/common/http';
|
||||
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
|
||||
import {UserService} from './user.service';
|
||||
import {environment} from '../../../environments/environment.development';
|
||||
@@ -11,7 +11,7 @@ describe('UserService', () => {
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(),
|
||||
provideHttpClient(withXhr()),
|
||||
provideHttpClientTesting(),
|
||||
]
|
||||
});
|
||||
|
||||
+47
@@ -67,4 +67,51 @@ describe('ForgetPasswordPopupComponent', () => {
|
||||
|
||||
expect(toastService.show).toHaveBeenCalledWith('User not found', jasmine.any(Object));
|
||||
});
|
||||
|
||||
it('should emit close event on successful send', () => {
|
||||
const closeSpy = jasmine.createSpy();
|
||||
component.close.subscribe(closeSpy);
|
||||
|
||||
resetService.sendResetPasswordLink.and.returnValue(of({value: 'Email sent'}));
|
||||
component.resetPasswordEmail = 'test@example.com';
|
||||
component.isValidEmail = true;
|
||||
|
||||
component.confirmToSend();
|
||||
|
||||
expect(closeSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should show success toast on successful send', () => {
|
||||
resetService.sendResetPasswordLink.and.returnValue(of({value: 'Email sent'}));
|
||||
component.resetPasswordEmail = 'test@example.com';
|
||||
component.isValidEmail = true;
|
||||
|
||||
component.confirmToSend();
|
||||
|
||||
expect(toastService.show).toHaveBeenCalledWith('Email sent', jasmine.objectContaining({
|
||||
classname: 'bg-success text-light'
|
||||
}));
|
||||
});
|
||||
|
||||
it('should clear email on successful send', () => {
|
||||
resetService.sendResetPasswordLink.and.returnValue(of({value: 'Email sent'}));
|
||||
component.resetPasswordEmail = 'test@example.com';
|
||||
component.isValidEmail = true;
|
||||
|
||||
component.confirmToSend();
|
||||
|
||||
expect(component.resetPasswordEmail).toBe('');
|
||||
});
|
||||
|
||||
it('should clear email on error', () => {
|
||||
resetService.sendResetPasswordLink.and.returnValue(throwError(() => ({
|
||||
error: {error: {message: 'Error'}}
|
||||
})));
|
||||
component.resetPasswordEmail = 'test@example.com';
|
||||
component.isValidEmail = true;
|
||||
|
||||
component.confirmToSend();
|
||||
|
||||
expect(component.resetPasswordEmail).toBe('');
|
||||
});
|
||||
});
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
import {Component, EventEmitter, inject, Output} from '@angular/core';
|
||||
import {Component, EventEmitter, inject, Output, ChangeDetectionStrategy} from '@angular/core';
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {ResetPasswordService} from "../../../infrastructure/services/reset-password.service";
|
||||
import {ToastService} from "../../../infrastructure/services/toast.service";
|
||||
@@ -10,6 +10,7 @@ import {LoadingService} from "../../../infrastructure/services/loading.service";
|
||||
FormsModule
|
||||
],
|
||||
templateUrl: './forget-password-popup.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './forget-password-popup.component.scss'
|
||||
})
|
||||
export class ForgetPasswordPopupComponent {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component, inject} from '@angular/core';
|
||||
import {Component, inject, ChangeDetectionStrategy} from '@angular/core';
|
||||
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {Router, RouterLink} from "@angular/router";
|
||||
import ValidateForm from "../../../infrastructure/utilities/validate-form";
|
||||
@@ -18,6 +18,7 @@ import {Title} from "@angular/platform-browser";
|
||||
],
|
||||
templateUrl: './login.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './login.component.scss'
|
||||
})
|
||||
export class LoginComponent {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component, inject} from '@angular/core';
|
||||
import {Component, inject, ChangeDetectionStrategy} from '@angular/core';
|
||||
import {Router, RouterLink} from "@angular/router";
|
||||
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
|
||||
@@ -17,6 +17,7 @@ import {Title} from "@angular/platform-browser";
|
||||
],
|
||||
templateUrl: './register.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './register.component.scss'
|
||||
})
|
||||
export class RegisterComponent {
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {Component, inject, OnInit, ChangeDetectionStrategy} from '@angular/core';
|
||||
import {FormBuilder, FormGroup, ReactiveFormsModule, Validators} from "@angular/forms";
|
||||
import {ResetPasswordService} from "../../../infrastructure/services/reset-password.service";
|
||||
import {ActivatedRoute, Router} from "@angular/router";
|
||||
@@ -15,6 +15,7 @@ import {PasswordValidator} from "../../../infrastructure/utilities/password-vali
|
||||
ReactiveFormsModule
|
||||
],
|
||||
templateUrl: './reset-password.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './reset-password.component.scss'
|
||||
})
|
||||
export class ResetPasswordComponent implements OnInit {
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import {RouterLink} from "@angular/router";
|
||||
|
||||
@Component({
|
||||
@@ -8,6 +8,7 @@ import {RouterLink} from "@angular/router";
|
||||
],
|
||||
templateUrl: './admin-dashboard.component.html',
|
||||
styleUrl: './admin-dashboard.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
standalone: true
|
||||
})
|
||||
export class AdminDashboardComponent {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {Component, ChangeDetectionStrategy} from '@angular/core';
|
||||
import {RouterLink} from "@angular/router";
|
||||
import {appVersion} from "../../../../environments/version";
|
||||
|
||||
@@ -7,6 +7,7 @@ import {appVersion} from "../../../../environments/version";
|
||||
imports: [RouterLink],
|
||||
templateUrl: './footer.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './footer.component.scss'
|
||||
})
|
||||
export class FooterComponent {
|
||||
|
||||
@@ -1,32 +1,68 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {HeaderComponent} from './header.component';
|
||||
import {AuthService} from '../../../infrastructure/services/auth-service';
|
||||
import {UserStoreService} from '../../../infrastructure/services/user-store.service';
|
||||
import {ActivatedRoute} from '@angular/router';
|
||||
|
||||
describe('HeaderComponent', () => {
|
||||
let component: HeaderComponent;
|
||||
let fixture: ComponentFixture<HeaderComponent>;
|
||||
let authService: jasmine.SpyObj<AuthService>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const authSpy = jasmine.createSpyObj('AuthService', [
|
||||
'getUsernameFromToken', 'getRoleFromToken', 'getEmailFromToken', 'getUserIdFromToken',
|
||||
'isLoggedIn', 'getToken', 'signOut', 'decodedToken'
|
||||
authService = jasmine.createSpyObj('AuthService', [
|
||||
'getUsernameFromToken', 'getRoleFromToken', 'getEmailFromToken',
|
||||
'getUserIdFromToken', 'isLoggedIn', 'getToken', 'signOut', 'decodedToken'
|
||||
]);
|
||||
authService.getUsernameFromToken.and.returnValue('default-user');
|
||||
authService.getRoleFromToken.and.returnValue('User');
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HeaderComponent],
|
||||
providers: [
|
||||
{provide: AuthService, useValue: authSpy},
|
||||
{provide: ActivatedRoute, useValue: {snapshot: {data: {}}, firstChild: null}},
|
||||
{provide: AuthService, useValue: authService},
|
||||
{
|
||||
provide: ActivatedRoute,
|
||||
useValue: {snapshot: {data: {}}, firstChild: null}
|
||||
},
|
||||
UserStoreService,
|
||||
]
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(HeaderComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
});
|
||||
|
||||
it('should create', () => {
|
||||
fixture.detectChanges();
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should load username from store or token fallback', () => {
|
||||
const userStore = TestBed.inject(UserStoreService);
|
||||
userStore.setUsernameForStore('store-user');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.username).toBe('store-user');
|
||||
});
|
||||
|
||||
it('should fall back to token username when store is empty', () => {
|
||||
authService.getUsernameFromToken.and.returnValue('token-user');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.username).toBe('token-user');
|
||||
});
|
||||
|
||||
it('should load role from store or token fallback', () => {
|
||||
authService.getRoleFromToken.and.returnValue('Admin');
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.role).toBe('Admin');
|
||||
});
|
||||
|
||||
it('should call signOut on logout', () => {
|
||||
fixture.detectChanges();
|
||||
component.logOut();
|
||||
expect(authService.signOut).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component, inject, OnInit} from '@angular/core';
|
||||
import {Component, inject, OnInit, ChangeDetectionStrategy} from '@angular/core';
|
||||
import {DarkModeService} from '../../../infrastructure/services/dark-mode.service';
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {ActivatedRoute, NavigationEnd, Router, RouterLink} from "@angular/router";
|
||||
@@ -14,6 +14,7 @@ import {filter} from "rxjs/operators";
|
||||
],
|
||||
templateUrl: './header.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './header.component.scss'
|
||||
})
|
||||
export class HeaderComponent implements OnInit {
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-startpage',
|
||||
imports: [],
|
||||
templateUrl: './startpage.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './startpage.component.scss',
|
||||
})
|
||||
export class StartpageComponent {
|
||||
|
||||
+2
-1
@@ -1,9 +1,10 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-unauthorized',
|
||||
imports: [],
|
||||
templateUrl: './unauthorized.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './unauthorized.component.scss',
|
||||
})
|
||||
export class UnauthorizedComponent {
|
||||
|
||||
+2
-1
@@ -1,4 +1,4 @@
|
||||
import {Component, inject} from '@angular/core';
|
||||
import {Component, inject, ChangeDetectionStrategy} from '@angular/core';
|
||||
import {Title} from "@angular/platform-browser";
|
||||
import {UserStoreService} from "../../../infrastructure/services/user-store.service";
|
||||
import {AuthService} from "../../../infrastructure/services/auth-service";
|
||||
@@ -7,6 +7,7 @@ import {AuthService} from "../../../infrastructure/services/auth-service";
|
||||
selector: 'app-user-dashboard',
|
||||
imports: [],
|
||||
templateUrl: './user-dashboard.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './user-dashboard.component.scss',
|
||||
})
|
||||
export class UserDashboardComponent {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component, inject} from '@angular/core';
|
||||
import {Component, inject, ChangeDetectionStrategy} from '@angular/core';
|
||||
import {User} from "../../../domain/entities/user";
|
||||
import {UserService} from "../../../infrastructure/services/user.service";
|
||||
import {ToastService} from "../../../infrastructure/services/toast.service";
|
||||
@@ -15,6 +15,7 @@ import {DatePipe, NgClass} from "@angular/common";
|
||||
],
|
||||
templateUrl: './user-table.component.html',
|
||||
styleUrl: './user-table.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
standalone: true
|
||||
})
|
||||
export class UserTableComponent {
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
import {TestBed} from '@angular/core/testing';
|
||||
import {HttpClient, HttpInterceptorFn, provideHttpClient, withInterceptors} from '@angular/common/http';
|
||||
import {fakeAsync, TestBed, tick} from '@angular/core/testing';
|
||||
import {HttpClient, HttpInterceptorFn, provideHttpClient, withInterceptors, withXhr} from '@angular/common/http';
|
||||
import {HttpTestingController, provideHttpClientTesting} from '@angular/common/http/testing';
|
||||
import {tokenInterceptor} from './token.interceptor';
|
||||
import {AuthService} from '../../infrastructure/services/auth-service';
|
||||
import {ToastService} from '../../infrastructure/services/toast.service';
|
||||
import {Router} from '@angular/router';
|
||||
import {of, throwError} from 'rxjs';
|
||||
|
||||
describe('tokenInterceptor', () => {
|
||||
let authService: jasmine.SpyObj<AuthService>;
|
||||
let toastService: jasmine.SpyObj<ToastService>;
|
||||
let router: jasmine.SpyObj<Router>;
|
||||
let httpClient: HttpClient;
|
||||
let httpTesting: HttpTestingController;
|
||||
|
||||
beforeEach(() => {
|
||||
authService = jasmine.createSpyObj('AuthService', ['getToken', 'getRefreshToken', 'getUserIdFromToken',
|
||||
'storeToken', 'storeRefreshToken', 'signOut', 'renewToken']);
|
||||
authService = jasmine.createSpyObj('AuthService', [
|
||||
'getToken', 'getRefreshToken', 'getUserIdFromToken',
|
||||
'storeToken', 'storeRefreshToken', 'signOut', 'renewToken'
|
||||
]);
|
||||
toastService = jasmine.createSpyObj('ToastService', ['show']);
|
||||
router = jasmine.createSpyObj('Router', ['navigate']);
|
||||
router.navigate.and.returnValue(Promise.resolve(true));
|
||||
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
provideHttpClient(withInterceptors([tokenInterceptor])),
|
||||
provideHttpClient(withXhr(), withInterceptors([tokenInterceptor])),
|
||||
provideHttpClientTesting(),
|
||||
{provide: AuthService, useValue: authService},
|
||||
{provide: ToastService, useValue: toastService},
|
||||
{provide: Router, useValue: router},
|
||||
]
|
||||
});
|
||||
httpClient = TestBed.inject(HttpClient);
|
||||
@@ -47,4 +59,61 @@ describe('tokenInterceptor', () => {
|
||||
expect(req.request.headers.has('Authorization')).toBeFalse();
|
||||
req.flush({});
|
||||
});
|
||||
|
||||
it('should retry with new token on 401 and refresh succeeds', () => {
|
||||
authService.getToken.and.returnValues('expired-token', 'new-access-token');
|
||||
authService.getRefreshToken.and.returnValue('valid-refresh');
|
||||
authService.getUserIdFromToken.and.returnValue(1);
|
||||
authService.renewToken.and.returnValue(of({
|
||||
value: {accessToken: 'new-access-token', refreshToken: 'new-refresh-token'}
|
||||
}));
|
||||
|
||||
const results: any[] = [];
|
||||
httpClient.get('/api/data').subscribe({next: r => results.push(r)});
|
||||
|
||||
const req1 = httpTesting.expectOne('/api/data');
|
||||
req1.flush('Unauthorized', {status: 401, statusText: 'Unauthorized'});
|
||||
|
||||
const req2 = httpTesting.expectOne('/api/data');
|
||||
expect(req2.request.headers.get('Authorization')).toBe('Bearer new-access-token');
|
||||
req2.flush({data: 'success'});
|
||||
|
||||
expect(results).toEqual([{data: 'success'}]);
|
||||
expect(authService.storeToken).toHaveBeenCalledWith('new-access-token');
|
||||
expect(authService.storeRefreshToken).toHaveBeenCalledWith('new-refresh-token');
|
||||
});
|
||||
|
||||
it('should sign out and redirect on 401 when refresh fails', fakeAsync(() => {
|
||||
authService.getToken.and.returnValue('expired-token');
|
||||
authService.getRefreshToken.and.returnValue('expired-refresh');
|
||||
authService.getUserIdFromToken.and.returnValue(1);
|
||||
authService.renewToken.and.returnValue(throwError(() => ({
|
||||
error: {error: {message: 'Token expired'}}
|
||||
})));
|
||||
|
||||
httpClient.get('/api/data').subscribe({error: () => {}});
|
||||
|
||||
const req = httpTesting.expectOne('/api/data');
|
||||
req.flush('Unauthorized', {status: 401, statusText: 'Unauthorized'});
|
||||
tick();
|
||||
|
||||
expect(authService.signOut).toHaveBeenCalled();
|
||||
expect(router.navigate).toHaveBeenCalledWith(['login']);
|
||||
expect(toastService.show).toHaveBeenCalledWith('Token expired', jasmine.objectContaining({
|
||||
classname: 'bg-warning text-light'
|
||||
}));
|
||||
}));
|
||||
|
||||
it('should pass through non-401 errors', () => {
|
||||
authService.getToken.and.returnValue('some-token');
|
||||
|
||||
const errors: any[] = [];
|
||||
httpClient.get('/api/test').subscribe({error: e => errors.push(e)});
|
||||
|
||||
const req = httpTesting.expectOne('/api/test');
|
||||
req.flush('Forbidden', {status: 403, statusText: 'Forbidden'});
|
||||
|
||||
expect(errors.length).toBe(1);
|
||||
expect(errors[0].status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
+28
-5
@@ -1,6 +1,5 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
|
||||
import { PopupModalComponent } from './popup-modal.component';
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
import {PopupModalComponent} from './popup-modal.component';
|
||||
|
||||
describe('PopupModalComponent', () => {
|
||||
let component: PopupModalComponent;
|
||||
@@ -9,8 +8,7 @@ describe('PopupModalComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [PopupModalComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}).compileComponents();
|
||||
|
||||
fixture = TestBed.createComponent(PopupModalComponent);
|
||||
component = fixture.componentInstance;
|
||||
@@ -20,4 +18,29 @@ describe('PopupModalComponent', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should have default headerClass', () => {
|
||||
expect(component.headerClass).toBe('bg-light');
|
||||
});
|
||||
|
||||
it('should bind title input', () => {
|
||||
component.title = 'Warning';
|
||||
fixture.detectChanges();
|
||||
const titleEl: HTMLElement = fixture.nativeElement.querySelector('.modal-title');
|
||||
expect(titleEl.textContent).toContain('Warning');
|
||||
});
|
||||
|
||||
it('should bind body input', () => {
|
||||
component.body = 'Are you sure?';
|
||||
fixture.detectChanges();
|
||||
const bodyEl: HTMLElement = fixture.nativeElement.querySelector('.modal-body p');
|
||||
expect(bodyEl.textContent).toContain('Are you sure?');
|
||||
});
|
||||
|
||||
it('should emit confirm on onConfirm', () => {
|
||||
const spy = jasmine.createSpy();
|
||||
component.confirm.subscribe(spy);
|
||||
component.onConfirm();
|
||||
expect(spy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component, EventEmitter, Input, Output} from '@angular/core';
|
||||
import {Component, EventEmitter, Input, Output, ChangeDetectionStrategy} from '@angular/core';
|
||||
import {NgClass} from "@angular/common";
|
||||
|
||||
@Component({
|
||||
@@ -8,6 +8,7 @@ import {NgClass} from "@angular/common";
|
||||
],
|
||||
templateUrl: './popup-modal.component.html',
|
||||
styleUrl: './popup-modal.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
standalone: true
|
||||
})
|
||||
export class PopupModalComponent {
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
import {ComponentFixture, TestBed} from '@angular/core/testing';
|
||||
|
||||
import {ToastComponent} from './toast.component';
|
||||
import {ToastService} from '../../../infrastructure/services/toast.service';
|
||||
|
||||
describe('ToastComponent', () => {
|
||||
let component: ToastComponent;
|
||||
let fixture: ComponentFixture<ToastComponent>;
|
||||
let toastService: ToastService;
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [ToastComponent]
|
||||
})
|
||||
.compileComponents();
|
||||
}).compileComponents();
|
||||
|
||||
toastService = TestBed.inject(ToastService);
|
||||
fixture = TestBed.createComponent(ToastComponent);
|
||||
component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
@@ -20,4 +21,45 @@ describe('ToastComponent', () => {
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should render toasts from service', () => {
|
||||
toastService.show('Hello World', {classname: 'bg-info'});
|
||||
fixture.detectChanges();
|
||||
|
||||
const toastEl = fixture.nativeElement.querySelector('.toast');
|
||||
expect(toastEl).toBeTruthy();
|
||||
expect(toastEl.textContent).toContain('Hello World');
|
||||
expect(toastEl.classList).toContain('bg-info');
|
||||
});
|
||||
|
||||
it('should render multiple toasts', () => {
|
||||
toastService.show('First');
|
||||
toastService.show('Second');
|
||||
fixture.detectChanges();
|
||||
|
||||
const toastEls = fixture.nativeElement.querySelectorAll('.toast');
|
||||
expect(toastEls.length).toBe(2);
|
||||
});
|
||||
|
||||
it('should remove toast on close button click', () => {
|
||||
toastService.show('Dismiss me');
|
||||
fixture.detectChanges();
|
||||
|
||||
const closeBtn = fixture.nativeElement.querySelector('.btn-close');
|
||||
closeBtn.click();
|
||||
fixture.detectChanges();
|
||||
|
||||
const toastEls = fixture.nativeElement.querySelectorAll('.toast');
|
||||
expect(toastEls.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should stop rendering after toast is removed', () => {
|
||||
toastService.show('Temporary');
|
||||
toastService.show('Persistent');
|
||||
toastService.remove(toastService.toasts[0]);
|
||||
fixture.detectChanges();
|
||||
|
||||
const toastEls = fixture.nativeElement.querySelectorAll('.toast');
|
||||
expect(toastEls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {Component, inject} from '@angular/core';
|
||||
import {Component, inject, ChangeDetectionStrategy} from '@angular/core';
|
||||
import {ToastService} from "../../../infrastructure/services/toast.service";
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import {ToastService} from "../../../infrastructure/services/toast.service";
|
||||
imports: [],
|
||||
templateUrl: './toast.component.html',
|
||||
standalone: true,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrls: ['./toast.component.scss']
|
||||
})
|
||||
export class ToastComponent {
|
||||
|
||||
@@ -11,5 +11,13 @@
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"extendedDiagnostics": {
|
||||
"checks": {
|
||||
"nullishCoalescingNotNullable": "suppress",
|
||||
"optionalChainNotNullable": "suppress"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,5 +11,13 @@
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"extendedDiagnostics": {
|
||||
"checks": {
|
||||
"nullishCoalescingNotNullable": "suppress",
|
||||
"optionalChainNotNullable": "suppress"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,20 +12,20 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.9" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Core" Version="2.3.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.AspNetCore" Version="2.3.11" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Server.Kestrel.Core" Version="2.3.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.9" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.9">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.7" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.0">
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -8,11 +8,11 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.0">
|
||||
<PackageReference Include="coverlet.collector" Version="10.0.1">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
using Application.Common.Results;
|
||||
|
||||
namespace Application.UnitTest.Common.Results;
|
||||
|
||||
public class ResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void Success_ShouldReturnResultWithIsSuccessTrue()
|
||||
{
|
||||
var result = Result.Success();
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.False(result.IsFailure);
|
||||
Assert.Equal(ErrorTypeConstant.None, result.Error.Code);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Success_WithValue_ShouldReturnResultWithValue()
|
||||
{
|
||||
var result = Result.Success("test-value");
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.Equal("test-value", result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_ShouldReturnResultWithIsFailureTrue()
|
||||
{
|
||||
var error = new Error("TestError", "Something went wrong");
|
||||
var result = Result.Failure(error);
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.False(result.IsSuccess);
|
||||
Assert.Equal("TestError", result.Error.Code);
|
||||
Assert.Equal("Something went wrong", result.Error.Message);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Failure_WithValueType_ShouldReturnTypedResult()
|
||||
{
|
||||
var error = new Error("NotFound", "Item not found");
|
||||
var result = Result.Failure<string>(error);
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Throws<InvalidOperationException>(() => result.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ShouldThrow_WhenSuccessWithError()
|
||||
{
|
||||
var error = new Error("SomeError", "message");
|
||||
Assert.Throws<InvalidOperationException>(() => new ResultTestCase(true, error));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ShouldThrow_WhenFailureWithNoError()
|
||||
{
|
||||
var noneError = new Error("None", string.Empty);
|
||||
Assert.Throws<InvalidOperationException>(() => new ResultTestCase(false, noneError));
|
||||
}
|
||||
}
|
||||
|
||||
public class ResultTestCase : Result
|
||||
{
|
||||
public ResultTestCase(bool isSuccess, Error error) : base(isSuccess, error)
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using Application.DTOs;
|
||||
|
||||
namespace Application.UnitTest.DTOs;
|
||||
|
||||
public class PagedResultTests
|
||||
{
|
||||
[Fact]
|
||||
public void TotalPages_ShouldCalculateCorrectly_WhenExactDivision()
|
||||
{
|
||||
var paged = new PagedResult<string>
|
||||
{
|
||||
Items = ["a", "b", "c", "d", "e"],
|
||||
TotalCount = 5,
|
||||
PageNumber = 1,
|
||||
PageSize = 5
|
||||
};
|
||||
Assert.Equal(1, paged.TotalPages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalPages_ShouldRoundUp_WhenNotExactDivision()
|
||||
{
|
||||
var paged = new PagedResult<string>
|
||||
{
|
||||
Items = ["a", "b", "c"],
|
||||
TotalCount = 10,
|
||||
PageNumber = 1,
|
||||
PageSize = 3
|
||||
};
|
||||
Assert.Equal(4, paged.TotalPages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TotalPages_ShouldBeZero_WhenTotalCountIsZero()
|
||||
{
|
||||
var paged = new PagedResult<string>
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 0,
|
||||
PageNumber = 1,
|
||||
PageSize = 10
|
||||
};
|
||||
Assert.Equal(0, paged.TotalPages);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPreviousPage_ShouldBeFalse_WhenOnFirstPage()
|
||||
{
|
||||
var paged = new PagedResult<string>
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 20,
|
||||
PageNumber = 1,
|
||||
PageSize = 10
|
||||
};
|
||||
Assert.False(paged.HasPreviousPage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasPreviousPage_ShouldBeTrue_WhenPastFirstPage()
|
||||
{
|
||||
var paged = new PagedResult<string>
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 20,
|
||||
PageNumber = 2,
|
||||
PageSize = 10
|
||||
};
|
||||
Assert.True(paged.HasPreviousPage);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasNext_ShouldBeFalse_WhenOnLastPage()
|
||||
{
|
||||
var paged = new PagedResult<string>
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 20,
|
||||
PageNumber = 2,
|
||||
PageSize = 10
|
||||
};
|
||||
Assert.False(paged.HasNext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasNext_ShouldBeTrue_WhenMorePagesExist()
|
||||
{
|
||||
var paged = new PagedResult<string>
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 20,
|
||||
PageNumber = 1,
|
||||
PageSize = 10
|
||||
};
|
||||
Assert.True(paged.HasNext);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void HasNext_ShouldBeFalse_WhenOnlyOnePage()
|
||||
{
|
||||
var paged = new PagedResult<string>
|
||||
{
|
||||
Items = [],
|
||||
TotalCount = 3,
|
||||
PageNumber = 1,
|
||||
PageSize = 10
|
||||
};
|
||||
Assert.False(paged.HasNext);
|
||||
}
|
||||
}
|
||||
@@ -134,6 +134,28 @@ public class AuthenticationServiceTests
|
||||
Assert.Equal(AuthError.UserNotFound, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoginAsync_ShouldReturnFailure_WhenPasswordIsWrong()
|
||||
{
|
||||
var password = "ValidP@ss1!";
|
||||
var user = new User
|
||||
{
|
||||
Id = 1,
|
||||
Username = "testuser",
|
||||
Email = "test@example.com",
|
||||
Password = new PasswordHasher<User>().HashPassword(
|
||||
new User { Username = "testuser", Email = "test@example.com", Password = password },
|
||||
password)
|
||||
};
|
||||
var request = new LoginRequest(user.Email, "WrongPassword1!");
|
||||
_userRepository.GetUserByEmailAsync(request.Email).Returns(user);
|
||||
|
||||
var result = await _sut.LoginAsync(request);
|
||||
|
||||
Assert.True(result.IsFailure);
|
||||
Assert.Equal(AuthError.InvalidPassword, result.Error);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RefreshTokensAsync_ShouldReturnSuccess_WhenRefreshTokenIsValid()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
using Application.Models;
|
||||
using Application.Services;
|
||||
using MailKit.Net.Smtp;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using MimeKit;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Application.UnitTest.Services;
|
||||
|
||||
public class EmailServiceTests
|
||||
{
|
||||
private readonly IConfiguration _configuration = Substitute.For<IConfiguration>();
|
||||
private readonly ISmtpClient _smtpClient = Substitute.For<ISmtpClient>();
|
||||
private readonly EmailService _sut;
|
||||
|
||||
public EmailServiceTests()
|
||||
{
|
||||
_configuration["EmailSettings:From"].Returns("sender@gmail.com");
|
||||
_configuration["EmailSettings:SmtpServer"].Returns("smtp.gmail.com");
|
||||
_configuration["EmailSettings:Password"].Returns("app-password");
|
||||
|
||||
_sut = new EmailService(_configuration, () => _smtpClient);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendEmailAsync_ShouldConnectAndAuthenticate()
|
||||
{
|
||||
var request = new EmailRequest("recipient@example.com", "Test Subject", "<p>Hello</p>");
|
||||
|
||||
_sut.SendEmailAsync(request);
|
||||
|
||||
_smtpClient.Received(1).Connect("smtp.gmail.com", 465, true);
|
||||
_smtpClient.Received(1).Authenticate("sender@gmail.com", "app-password");
|
||||
_smtpClient.Received(1).Send(Arg.Any<MimeMessage>());
|
||||
_smtpClient.Received(1).Disconnect(true);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendEmailAsync_ShouldSendMessageWithCorrectDetails()
|
||||
{
|
||||
var request = new EmailRequest("recipient@example.com", "Welcome!", "<h1>Hi</h1>");
|
||||
|
||||
_sut.SendEmailAsync(request);
|
||||
|
||||
_smtpClient.Received(1).Send(Arg.Is<MimeMessage>(msg =>
|
||||
msg.To.Mailboxes.Any(m => m.Address == "recipient@example.com") &&
|
||||
msg.Subject == "Welcome!"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SendEmailAsync_ShouldDisposeClient_AfterSending()
|
||||
{
|
||||
var request = new EmailRequest("recipient@example.com", "Subject", "Body");
|
||||
|
||||
_sut.SendEmailAsync(request);
|
||||
|
||||
_smtpClient.Received(1).Disconnect(true);
|
||||
_smtpClient.Received(1).Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
using System.IdentityModel.Tokens.Jwt;
|
||||
using Application.Services;
|
||||
using Domain.Entities;
|
||||
using Domain.Interface;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Application.UnitTest.Services;
|
||||
|
||||
public class JwtServiceTests
|
||||
{
|
||||
private readonly IConfiguration _configuration = Substitute.For<IConfiguration>();
|
||||
private readonly IUserRepository _userRepository = Substitute.For<IUserRepository>();
|
||||
private readonly IUnitOfWork _unitOfWork = Substitute.For<IUnitOfWork>();
|
||||
private readonly JwtService _sut;
|
||||
|
||||
public JwtServiceTests()
|
||||
{
|
||||
var section = Substitute.For<IConfigurationSection>();
|
||||
section.Value.Returns("veryveryveryveryveryveryverysecretkey");
|
||||
_configuration.GetSection("Jwt:Key").Returns(section);
|
||||
|
||||
_configuration["Jwt:Key"].Returns("veryveryveryveryveryveryverysecretkey");
|
||||
_configuration["Jwt:Issuer"].Returns("https://localhost:7091");
|
||||
_configuration["Jwt:Audience"].Returns("http://localhost:5184");
|
||||
|
||||
_sut = new JwtService(_configuration, _userRepository, _unitOfWork);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateTokenAsync_ShouldReturnToken_WhenUserIsValid()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = 1,
|
||||
Username = "testuser",
|
||||
Email = "test@example.com",
|
||||
Password = "hash"
|
||||
};
|
||||
_userRepository.GetUserRolesByEmailAsync(user.Email)
|
||||
.Returns(["User"]);
|
||||
|
||||
var token = await _sut.GenerateTokenAsync(user);
|
||||
|
||||
Assert.NotNull(token);
|
||||
Assert.NotEmpty(token);
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwtToken = handler.ReadJwtToken(token);
|
||||
|
||||
Assert.Equal("https://localhost:7091", jwtToken.Issuer);
|
||||
Assert.Contains(jwtToken.Claims, c => c.Type == "email" && c.Value == user.Email);
|
||||
Assert.Contains(jwtToken.Claims, c => c.Type == "UserId" && c.Value == "1");
|
||||
Assert.Contains(jwtToken.Claims, c => c.Type == "username" && c.Value == user.Username);
|
||||
Assert.Contains(jwtToken.Claims, c => c.Type == "role" && c.Value == "User");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateTokenAsync_ShouldIncludeAllRoles()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = 2,
|
||||
Username = "adminuser",
|
||||
Email = "admin@example.com",
|
||||
Password = "hash"
|
||||
};
|
||||
_userRepository.GetUserRolesByEmailAsync(user.Email)
|
||||
.Returns(["Admin", "User"]);
|
||||
|
||||
var token = await _sut.GenerateTokenAsync(user);
|
||||
|
||||
var handler = new JwtSecurityTokenHandler();
|
||||
var jwtToken = handler.ReadJwtToken(token);
|
||||
var roleClaims = jwtToken.Claims.Where(c => c.Type == "role").Select(c => c.Value).ToList();
|
||||
|
||||
Assert.Contains("Admin", roleClaims);
|
||||
Assert.Contains("User", roleClaims);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GenerateAndSaveRefreshTokenAsync_ShouldUpdateUserAndCommit()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = 1,
|
||||
Username = "testuser",
|
||||
Email = "test@example.com",
|
||||
Password = "hash"
|
||||
};
|
||||
|
||||
var refreshToken = await _sut.GenerateAndSaveRefreshTokenAsync(user);
|
||||
|
||||
Assert.NotNull(refreshToken);
|
||||
Assert.NotEmpty(refreshToken);
|
||||
Assert.Equal(refreshToken, user.RefreshToken);
|
||||
Assert.NotNull(user.RefreshTokenExpiryTime);
|
||||
Assert.True(user.RefreshTokenExpiryTime > DateTime.UtcNow);
|
||||
_userRepository.Received(1).Update(user);
|
||||
await _unitOfWork.Received(1).CommitAsync();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_ShouldReturnNull_WhenUserNotFound()
|
||||
{
|
||||
_userRepository.GetUserByIdAsync(99).Returns((User?)null);
|
||||
|
||||
var result = await _sut.ValidateRefreshTokenAsync(99, "some-token");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_ShouldReturnNull_WhenTokenMismatch()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = 1,
|
||||
Username = "testuser",
|
||||
Email = "test@example.com",
|
||||
Password = "hash",
|
||||
RefreshToken = "stored-token",
|
||||
RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(1)
|
||||
};
|
||||
_userRepository.GetUserByIdAsync(1).Returns(user);
|
||||
|
||||
var result = await _sut.ValidateRefreshTokenAsync(1, "wrong-token");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_ShouldReturnNull_WhenTokenExpired()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = 1,
|
||||
Username = "testuser",
|
||||
Email = "test@example.com",
|
||||
Password = "hash",
|
||||
RefreshToken = "expired-token",
|
||||
RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(-1)
|
||||
};
|
||||
_userRepository.GetUserByIdAsync(1).Returns(user);
|
||||
|
||||
var result = await _sut.ValidateRefreshTokenAsync(1, "expired-token");
|
||||
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ValidateRefreshTokenAsync_ShouldReturnUser_WhenTokenValid()
|
||||
{
|
||||
var user = new User
|
||||
{
|
||||
Id = 1,
|
||||
Username = "testuser",
|
||||
Email = "test@example.com",
|
||||
Password = "hash",
|
||||
RefreshToken = "valid-token",
|
||||
RefreshTokenExpiryTime = DateTime.UtcNow.AddDays(1)
|
||||
};
|
||||
_userRepository.GetUserByIdAsync(1).Returns(user);
|
||||
|
||||
var result = await _sut.ValidateRefreshTokenAsync(1, "valid-token");
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.Equal(1, result.Id);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user