Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c09beeb3c | ||
|
|
cbbfaf4910 | ||
|
|
c2f360c591 | ||
|
|
933f0a3360 |
@@ -43,6 +43,31 @@ describe('AuthService', () => {
|
||||
expect(service.isAuthenticated()).toBe(true);
|
||||
});
|
||||
|
||||
it('garde le mot de passe provisoire pour un seul changement quand il doit être changé', () => {
|
||||
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
|
||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
|
||||
...tokenResponse,
|
||||
principal: { ...tokenResponse.principal, must_change_password: true },
|
||||
});
|
||||
|
||||
expect(service.takeProvisionalPassword()).toBe('Provisoire');
|
||||
expect(service.takeProvisionalPassword()).toBeNull();
|
||||
});
|
||||
|
||||
it('ne garde aucun mot de passe quand il est déjà définitif, ni après la fin de session', () => {
|
||||
service.login({ email: 'a@a.com', password: 'Definitif' }).subscribe();
|
||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
||||
expect(service.takeProvisionalPassword()).toBeNull();
|
||||
|
||||
service.login({ email: 'a@a.com', password: 'Provisoire' }).subscribe();
|
||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush({
|
||||
...tokenResponse,
|
||||
principal: { ...tokenResponse.principal, must_change_password: true },
|
||||
});
|
||||
service.clearSession();
|
||||
expect(service.takeProvisionalPassword()).toBeNull();
|
||||
});
|
||||
|
||||
it('efface la session au logout', () => {
|
||||
service.login({ email: 'a@a.com', password: 'secret' }).subscribe();
|
||||
httpMock.expectOne(`${environment.apiUrl}/auth/login`).flush(tokenResponse);
|
||||
|
||||
@@ -19,6 +19,9 @@ export class AuthService {
|
||||
// mémoire. Un rechargement de page le perd, c'est voulu par le contrat.
|
||||
private accessTokenSignal = signal<string | null>(null);
|
||||
private principalSignal = signal<Principal | null>(null);
|
||||
// Pourquoi : redemander le mot de passe provisoire qu'on vient de vérifier laisse un gestionnaire
|
||||
// de mots de passe y coller un ancien mot de passe du site, et `/auth/password` répond 401.
|
||||
private provisionalPassword: string | null = null;
|
||||
|
||||
readonly principal = this.principalSignal.asReadonly();
|
||||
readonly isAuthenticated = computed(() => this.principalSignal() !== null);
|
||||
@@ -37,12 +40,26 @@ export class AuthService {
|
||||
clearSession(): void {
|
||||
this.accessTokenSignal.set(null);
|
||||
this.principalSignal.set(null);
|
||||
this.provisionalPassword = null;
|
||||
}
|
||||
|
||||
login(credentials: LoginRequest): Observable<TokenResponse> {
|
||||
return this.http
|
||||
.post<TokenResponse>(`${environment.apiUrl}/auth/login`, credentials, { withCredentials: true })
|
||||
.pipe(tap((response) => this.setSession(response)));
|
||||
.pipe(
|
||||
tap((response) => {
|
||||
this.setSession(response);
|
||||
this.provisionalPassword = response.principal.must_change_password
|
||||
? credentials.password
|
||||
: null;
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
takeProvisionalPassword(): string | null {
|
||||
const password = this.provisionalPassword;
|
||||
this.provisionalPassword = null;
|
||||
return password;
|
||||
}
|
||||
|
||||
// Un seul rafraîchissement en vol à la fois, partagé entre tous les
|
||||
|
||||
@@ -7,14 +7,18 @@
|
||||
Votre mot de passe est provisoire, vous devez le modifier avant de continuer
|
||||
</p>
|
||||
|
||||
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
||||
<input
|
||||
id="current_password"
|
||||
class="form-input"
|
||||
type="password"
|
||||
formControlName="current_password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
<input hidden type="email" autocomplete="username" [value]="email" readonly />
|
||||
|
||||
@if (asksCurrentPassword()) {
|
||||
<label class="form-label" for="current_password">Mot de passe actuel</label>
|
||||
<input
|
||||
id="current_password"
|
||||
class="form-input"
|
||||
type="password"
|
||||
formControlName="current_password"
|
||||
autocomplete="current-password"
|
||||
/>
|
||||
}
|
||||
|
||||
<label class="form-label" for="new_password">Nouveau mot de passe</label>
|
||||
<input
|
||||
@@ -24,7 +28,7 @@
|
||||
formControlName="new_password"
|
||||
autocomplete="new-password"
|
||||
/>
|
||||
<span class="form-hint">{{ passwordHint }}</span>
|
||||
<app-password-requirements [password]="newPassword()" />
|
||||
|
||||
@if (errorMessage()) {
|
||||
<ev-alert severity="danger">{{ errorMessage() }}</ev-alert>
|
||||
|
||||
@@ -1,17 +1,29 @@
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { signal } from '@angular/core';
|
||||
import { of, throwError } from 'rxjs';
|
||||
import { vi } from 'vitest';
|
||||
import { ChangePassword } from './change-password';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
|
||||
const NOUVEAU = 'Un-nouveau-mot-de-passe1!';
|
||||
|
||||
describe('ChangePassword', () => {
|
||||
let authMock: { changePassword: ReturnType<typeof vi.fn> };
|
||||
let authMock: {
|
||||
changePassword: ReturnType<typeof vi.fn>;
|
||||
takeProvisionalPassword: ReturnType<typeof vi.fn>;
|
||||
principal: ReturnType<typeof signal>;
|
||||
};
|
||||
let routerMock: { navigate: ReturnType<typeof vi.fn> };
|
||||
|
||||
beforeEach(async () => {
|
||||
authMock = { changePassword: vi.fn() };
|
||||
authMock = {
|
||||
changePassword: vi.fn(),
|
||||
takeProvisionalPassword: vi.fn().mockReturnValue(null),
|
||||
principal: signal({ email: 'johan@enervision.fr' }),
|
||||
};
|
||||
routerMock = { navigate: vi.fn() };
|
||||
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -23,6 +35,10 @@ describe('ChangePassword', () => {
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
function champActuel(fixture: { nativeElement: HTMLElement }): HTMLInputElement | null {
|
||||
return fixture.nativeElement.querySelector('#current_password');
|
||||
}
|
||||
|
||||
it('ne soumet pas si le formulaire est invalide (mot de passe trop court)', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
@@ -44,7 +60,7 @@ describe('ChangePassword', () => {
|
||||
it('redirige vers /dashboard après un changement réussi', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
|
||||
@@ -52,46 +68,94 @@ describe('ChangePassword', () => {
|
||||
expect(routerMock.navigate).toHaveBeenCalledWith(['/dashboard']);
|
||||
});
|
||||
|
||||
it("affiche un message d'erreur si le mot de passe actuel est incorrect", () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'mauvais-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
||||
it("demande le mot de passe actuel quand la connexion ne l'a pas transmis (page rechargée)", () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
fixture.detectChanges();
|
||||
|
||||
authMock.changePassword.mockReturnValue(throwError(() => new Error('401')));
|
||||
expect(champActuel(fixture)).not.toBeNull();
|
||||
});
|
||||
|
||||
component.onSubmit();
|
||||
fixture.detectChanges(); // rend le bloc @if (errorMessage())
|
||||
it('réutilise le mot de passe provisoire de la connexion sans le redemander', () => {
|
||||
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-24-caracteres');
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.errorMessage()).toContain('incorrect');
|
||||
const errorEl = fixture.nativeElement.querySelector('.ev-alert');
|
||||
expect(errorEl?.textContent).toContain('incorrect');
|
||||
expect(champActuel(fixture)).toBeNull();
|
||||
component.form.controls.new_password.setValue(NOUVEAU);
|
||||
component.onSubmit();
|
||||
|
||||
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||
current_password: 'Provisoire-24-caracteres',
|
||||
new_password: NOUVEAU,
|
||||
});
|
||||
});
|
||||
|
||||
it('associe le formulaire au compte connecté pour les gestionnaires de mots de passe', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
fixture.detectChanges();
|
||||
|
||||
const identifiant = fixture.nativeElement.querySelector('input[autocomplete="username"]');
|
||||
expect(identifiant.value).toBe('johan@enervision.fr');
|
||||
});
|
||||
|
||||
it('sur un 401, dit que le mot de passe actuel est faux et le redemande', () => {
|
||||
authMock.takeProvisionalPassword.mockReturnValue('Provisoire-perime');
|
||||
authMock.changePassword.mockReturnValue(
|
||||
throwError(() => new HttpErrorResponse({ status: 401 })),
|
||||
);
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.controls.new_password.setValue(NOUVEAU);
|
||||
|
||||
component.onSubmit();
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(component.errorMessage()).toContain('Mot de passe actuel incorrect');
|
||||
expect(fixture.nativeElement.querySelector('.ev-alert')?.textContent).toContain('incorrect');
|
||||
expect(champActuel(fixture)).not.toBeNull();
|
||||
expect(component.form.controls.current_password.value).toBe('');
|
||||
});
|
||||
|
||||
it('sur un 422, dit que le nouveau mot de passe ne respecte pas la politique', () => {
|
||||
authMock.changePassword.mockReturnValue(
|
||||
throwError(() => new HttpErrorResponse({ status: 422 })),
|
||||
);
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||
|
||||
component.onSubmit();
|
||||
|
||||
expect(component.errorMessage()).toContain('Nouveau mot de passe refusé');
|
||||
expect(component.form.controls.current_password.value).toBe('ancien-mot-de-passe');
|
||||
});
|
||||
|
||||
it('désactive le bouton tant que le formulaire est invalide', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
fixture.detectChanges();
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
fixture.detectChanges();
|
||||
|
||||
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
|
||||
const button = fixture.nativeElement.querySelector('button[type="submit"]');
|
||||
expect(button.disabled).toBe(true);
|
||||
expect(fixture.nativeElement.querySelector('.ev-alert')).toBeNull();
|
||||
});
|
||||
|
||||
it('déclenche onSubmit via la soumission réelle du formulaire (ngSubmit)', () => {
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: 'Un-nouveau-mot-de-passe1!' });
|
||||
fixture.detectChanges();
|
||||
const fixture = TestBed.createComponent(ChangePassword);
|
||||
const component = fixture.componentInstance;
|
||||
component.form.setValue({ current_password: 'ancien-mot-de-passe', new_password: NOUVEAU });
|
||||
fixture.detectChanges();
|
||||
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
authMock.changePassword.mockReturnValue(of({ principal: { role: 'admin' } }));
|
||||
|
||||
const form = fixture.nativeElement.querySelector('form');
|
||||
form.dispatchEvent(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
const form = fixture.nativeElement.querySelector('form');
|
||||
form.dispatchEvent(new Event('submit'));
|
||||
fixture.detectChanges();
|
||||
|
||||
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||
current_password: 'ancien-mot-de-passe',
|
||||
new_password: 'Un-nouveau-mot-de-passe1!',
|
||||
expect(authMock.changePassword).toHaveBeenCalledWith({
|
||||
current_password: 'ancien-mot-de-passe',
|
||||
new_password: NOUVEAU,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import { Component, inject, signal } from '@angular/core';
|
||||
import { toSignal } from '@angular/core/rxjs-interop';
|
||||
import { ReactiveFormsModule, FormBuilder, Validators } from '@angular/forms';
|
||||
import { Router } from '@angular/router';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { AuthService } from '../../../core/services/auth.service';
|
||||
import { Button } from '../../../shared/components/ui/button/button';
|
||||
import { Card } from '../../../shared/components/ui/card/card';
|
||||
import { Alert } from '../../../shared/components/ui/alert/alert';
|
||||
import { Brand } from '../../../shared/components/ui/brand/brand';
|
||||
import { PasswordRequirementsChecklist } from '../../../shared/components/password-requirements/password-requirements';
|
||||
import { passwordValidators, PASSWORD_HINT } from '../../../shared/validators/password.validator';
|
||||
|
||||
@Component({
|
||||
selector: 'app-change-password',
|
||||
standalone: true,
|
||||
imports: [ReactiveFormsModule, Button, Card, Alert, Brand],
|
||||
imports: [ReactiveFormsModule, Button, Card, Alert, Brand, PasswordRequirementsChecklist],
|
||||
templateUrl: './change-password.html',
|
||||
styleUrl: './change-password.scss',
|
||||
})
|
||||
@@ -20,30 +23,47 @@ export class ChangePassword {
|
||||
private auth = inject(AuthService);
|
||||
private router = inject(Router);
|
||||
|
||||
private provisionalPassword = this.auth.takeProvisionalPassword();
|
||||
|
||||
errorMessage = signal<string | null>(null);
|
||||
isLoading = signal(false);
|
||||
passwordHint = PASSWORD_HINT;
|
||||
asksCurrentPassword = signal(this.provisionalPassword === null);
|
||||
email = this.auth.principal()?.email ?? '';
|
||||
|
||||
form = this.fb.nonNullable.group({
|
||||
current_password: ['', Validators.required],
|
||||
current_password: [this.provisionalPassword ?? '', Validators.required],
|
||||
new_password: ['', passwordValidators],
|
||||
});
|
||||
|
||||
newPassword = toSignal(this.form.controls.new_password.valueChanges, { initialValue: '' });
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.form.invalid) return;
|
||||
this.isLoading.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
this.auth.changePassword(this.form.getRawValue()).subscribe({
|
||||
next: (response) => {
|
||||
next: () => {
|
||||
this.router.navigate(['/dashboard']);
|
||||
},
|
||||
error: () => {
|
||||
error: (error: HttpErrorResponse) => {
|
||||
this.isLoading.set(false);
|
||||
this.errorMessage.set(
|
||||
`Mot de passe actuel incorrect, ou nouveau mot de passe invalide (${this.passwordHint}).`,
|
||||
);
|
||||
this.errorMessage.set(this.explique(error));
|
||||
if (error.status === 401) {
|
||||
this.form.controls.current_password.reset('');
|
||||
this.asksCurrentPassword.set(true);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
private explique(error: HttpErrorResponse): string {
|
||||
if (error.status === 401) {
|
||||
return 'Mot de passe actuel incorrect : saisissez le mot de passe provisoire qui vous a été transmis.';
|
||||
}
|
||||
if (error.status === 422) {
|
||||
return `Nouveau mot de passe refusé (${PASSWORD_HINT}).`;
|
||||
}
|
||||
return 'Le changement de mot de passe a échoué, réessayez dans un instant.';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,7 @@ l'[ADR 0008](../adr/0008-airflow-execute-le-code-du-backend.md).
|
||||
| `ml_score` | `0 * * * *` | `enervision_ml.score`, dans `/opt/ml/.venv` |
|
||||
| `alertes` | `15 * * * *` | `app.detection.internal_alerts` puis `app.cli generate-recommendations`, dans `/opt/backend/.venv` |
|
||||
| `historical_import` | manuelle | `app.etl.historical_import`, dans `/opt/backend/.venv` ; les fichiers de `data/raw` sont montés en lecture seule dans `/opt/data/raw` |
|
||||
| `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe l'heure précédant son déclenchement depuis l'API Mock |
|
||||
| `mock_api_import` | `45 * * * *` | `app.etl.mock_api_import`, dans `/opt/backend/.venv` ; importe depuis l'API Mock la mesure de l'heure pile précédant son déclenchement |
|
||||
| `derive` | `30 5 * * *` | `app.monitoring.drift`, dans `/opt/backend/.venv` ; quotidien parce que sa fenêtre couvre 168 h, et sans reprise parce qu'une dérive n'est pas une panne passagère |
|
||||
|
||||
Le DAG `historical_import` réutilise le pipeline historique existant sans dupliquer sa logique.
|
||||
|
||||
+5
-3
@@ -669,13 +669,15 @@ des recommandations (`alertes`, issue #116), l'import historique (`historical_im
|
||||
issue #119) et l'import périodique de l'API Mock (`mock_api_import`, issue #15).
|
||||
|
||||
Le DAG `mock_api_import` s'exécute chaque heure, à la minute `:45`. Il appelle
|
||||
`app.etl.mock_api_import` avec un intervalle explicite d'une heure et une limite de 1 000 lectures
|
||||
par site. Les deux pipelines normalisent leurs données vers les tables communes `site` et
|
||||
`app.etl.mock_api_import` sur l'intervalle qui va de l'heure pile à son déclenchement, avec une
|
||||
limite d'une lecture par site. L'API Mock génère autant de points que la limite demandée,
|
||||
répartis sur l'intervalle et le premier à son début : un seul donne la mesure de :00, au pas
|
||||
horaire du dataset historique, que les features ML supposent en décalant par ligne. Les deux pipelines normalisent leurs données vers les tables communes `site` et
|
||||
`reading`, tout en conservant leur source (`csv` ou `api_history`). La réconciliation globale
|
||||
des deux sources reste à compléter dans l'issue #15.
|
||||
|
||||
Le DAG `mock_api_import` exécute `app.etl.mock_api_import` toutes les heures. Chaque exécution
|
||||
traite l'intervalle Airflow précédent. Les deux pipelines normalisent leurs données vers les
|
||||
importe la mesure de l'heure pile qui précède son déclenchement. Les deux pipelines normalisent leurs données vers les
|
||||
tables communes `site` et `reading`, tout en conservant leur source (`csv` ou `api_history`).
|
||||
|
||||
Airflow permet de planifier les traitements, gérer leur ordre d'exécution, suivre leur état et remonter les erreurs. Il ne remplace pas la logique ETL Python existante : les scripts actuels restent responsables de l'extraction, de la validation, de la transformation et du chargement. `etl/airflow/dags/ml_train.py`, `ml_score.py`, `alertes.py`, `historical_import.py` et
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""DAG d'import périodique des données de l'API Mock EnerVision (issue #15).
|
||||
|
||||
Orchestre le pipeline existant `app.etl.mock_api_import` sans dupliquer sa logique ETL.
|
||||
Chaque exécution traite l'heure précédant son déclenchement.
|
||||
Chaque exécution importe la mesure de l'heure pile qui précède son déclenchement.
|
||||
|
||||
Le pipeline backend reste responsable de la validation, de la normalisation, du suivi de la
|
||||
qualité, de l'idempotence et du chargement dans PostgreSQL/TimescaleDB.
|
||||
@@ -18,9 +18,9 @@ from airflow.timetables.trigger import CronTriggerTimetable
|
||||
# Le backend possède son propre environnement uv dans l'image Airflow (ADR 0008).
|
||||
COMMANDE_BACKEND = "cd /opt/backend && env -u VIRTUAL_ENV uv run --no-sync python -m"
|
||||
|
||||
# Le pipeline backend et l'API acceptent au maximum 1 000 lectures par site.
|
||||
# Cette marge évite de perdre silencieusement une lecture si une heure en contient plus de 60.
|
||||
LIMITE_LECTURES = 1000
|
||||
# Contrainte : l'API Mock génère `limit` points répartis sur l'intervalle, le premier à son début.
|
||||
# Un seul, depuis l'heure pile, donne la mesure de :00 au pas du CSV que suppose `shift(168)`.
|
||||
LIMITE_LECTURES = 1
|
||||
|
||||
# Deux reprises donnent trois tentatives au total. Même dans le pire cas, l'exécution reste
|
||||
# inférieure au pas horaire du DAG.
|
||||
@@ -51,7 +51,7 @@ with DAG(
|
||||
task_id="import_mock_api",
|
||||
bash_command=(
|
||||
f"{COMMANDE_BACKEND} app.etl.mock_api_import "
|
||||
"--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\" "
|
||||
"--start-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:00:00') }}\" "
|
||||
"--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\" "
|
||||
f"--limit {LIMITE_LECTURES}"
|
||||
),
|
||||
|
||||
@@ -113,12 +113,12 @@ def test_mock_api_import_calls_the_existing_backend_module(dagbag: DagBag) -> No
|
||||
assert "app.etl.mock_api_import" in commande
|
||||
|
||||
|
||||
def test_mock_api_import_uses_the_airflow_data_interval(dagbag: DagBag) -> None:
|
||||
def test_mock_api_import_asks_for_the_on_the_hour_reading(dagbag: DagBag) -> None:
|
||||
commande = dagbag.dags["mock_api_import"].get_task("import_mock_api").bash_command
|
||||
|
||||
assert "--start-time \"{{ data_interval_start.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
|
||||
assert "--start-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:00:00') }}\"" in commande
|
||||
assert "--end-time \"{{ data_interval_end.strftime('%Y-%m-%dT%H:%M:%S') }}\"" in commande
|
||||
assert "--limit 1000" in commande
|
||||
assert commande.endswith("--limit 1")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("task_id", ["detection", "recommandations"])
|
||||
|
||||
@@ -12,7 +12,7 @@ test('impose le changement du mot de passe temporaire avant le tableau de bord',
|
||||
await expect(page).toHaveURL(/\/change-password$/);
|
||||
await expect(page.getByRole('heading', { name: 'Nouveau mot de passe' })).toBeVisible();
|
||||
|
||||
await page.getByLabel('Mot de passe actuel').fill(compte.password);
|
||||
await expect(page.getByLabel('Mot de passe actuel')).toHaveCount(0);
|
||||
await page.getByLabel('Nouveau mot de passe').fill(nouveauMotDePasse());
|
||||
await page.getByRole('button', { name: 'Valider' }).click();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user