Skip to content

Commit 79d1286

Browse files
committed
sa: detect faulted job, add draft-jobs.component tests
`BuildCompletedAsync` occurs and can alert about a Faulted job. Its sf project id is in payload.
1 parent de136f1 commit 79d1286

File tree

4 files changed

+624
-4
lines changed

4 files changed

+624
-4
lines changed

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.html

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@
2727
<div class="table-wrapper">
2828
<table mat-table id="draft-jobs-table" [dataSource]="rows">
2929
<tr mat-header-row *matHeaderRowDef="columnsToDisplay"></tr>
30-
<tr mat-row *matRowDef="let row; columns: columnsToDisplay"></tr>
30+
<tr class="job-row" mat-row *matRowDef="let row; columns: columnsToDisplay"></tr>
3131
<ng-container matColumnDef="status">
3232
<th mat-header-cell *matHeaderCellDef>Status</th>
3333
<td mat-cell *matCellDef="let row">
@@ -159,7 +159,7 @@
159159
</div>
160160

161161
@if (rows.length === 0) {
162-
<p>No draft jobs found for the selected time period.</p>
162+
<p id="no-draft-jobs-found-message">No draft jobs found for the selected time period.</p>
163163
}
164164
}
165165
} @else {
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http';
2+
import { provideHttpClientTesting } from '@angular/common/http/testing';
3+
import { DebugElement } from '@angular/core';
4+
import { ComponentFixture, fakeAsync, flush, TestBed } from '@angular/core/testing';
5+
import { By } from '@angular/platform-browser';
6+
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
7+
import { ActivatedRoute, RouterModule } from '@angular/router';
8+
import { BehaviorSubject } from 'rxjs';
9+
import { anything, mock, verify, when } from 'ts-mockito';
10+
import { AuthService } from 'xforge-common/auth.service';
11+
import { DialogService } from 'xforge-common/dialog.service';
12+
import { I18nService } from 'xforge-common/i18n.service';
13+
import { OnlineStatusService } from 'xforge-common/online-status.service';
14+
import { TestOnlineStatusModule } from 'xforge-common/test-online-status.module';
15+
import { TestOnlineStatusService } from 'xforge-common/test-online-status.service';
16+
import { TestRealtimeModule } from 'xforge-common/test-realtime.module';
17+
import { TestRealtimeService } from 'xforge-common/test-realtime.service';
18+
import { configureTestingModule, TestTranslocoModule } from 'xforge-common/test-utils';
19+
import { SF_TYPE_REGISTRY } from '../core/models/sf-type-registry';
20+
import { SFProjectService } from '../core/sf-project.service';
21+
import { EventMetric, EventScope } from '../event-metrics/event-metric';
22+
import { DraftJob, DraftJobsComponent } from './draft-jobs.component';
23+
import sampleEvents from './sample-events.json';
24+
import { ServalAdministrationService } from './serval-administration.service';
25+
26+
const mockedActivatedRoute = mock(ActivatedRoute);
27+
const mockedAuthService = mock(AuthService);
28+
const mockedDialogService = mock(DialogService);
29+
const mockedI18nService = mock(I18nService);
30+
const mockedProjectService = mock(SFProjectService);
31+
const mockedServalAdministrationService = mock(ServalAdministrationService);
32+
33+
describe('DraftJobsComponent', () => {
34+
configureTestingModule(() => ({
35+
imports: [
36+
NoopAnimationsModule,
37+
TestTranslocoModule,
38+
TestOnlineStatusModule.forRoot(),
39+
TestRealtimeModule.forRoot(SF_TYPE_REGISTRY),
40+
RouterModule.forRoot([])
41+
],
42+
providers: [
43+
provideHttpClient(withInterceptorsFromDi()),
44+
provideHttpClientTesting(),
45+
{ provide: ActivatedRoute, useMock: mockedActivatedRoute },
46+
{ provide: AuthService, useMock: mockedAuthService },
47+
{ provide: DialogService, useMock: mockedDialogService },
48+
{ provide: I18nService, useMock: mockedI18nService },
49+
{ provide: OnlineStatusService, useClass: TestOnlineStatusService },
50+
{ provide: SFProjectService, useMock: mockedProjectService },
51+
{ provide: ServalAdministrationService, useMock: mockedServalAdministrationService }
52+
]
53+
}));
54+
55+
it('should show jobs from event metrics', fakeAsync(() => {
56+
const env = new TestEnvironment();
57+
env.wait();
58+
expect(env.component.rows.length).toBeGreaterThan(0);
59+
expect(env.rows.length).toBeGreaterThan(0);
60+
expect(env.emptyMessage).toBeNull();
61+
}));
62+
63+
it('should display empty state when no jobs', fakeAsync(() => {
64+
const env = new TestEnvironment({ hasEvents: false });
65+
env.wait();
66+
expect(env.rows.length).toBe(0);
67+
expect(env.emptyMessage).not.toBeNull();
68+
expect(env.emptyMessage!.nativeElement.textContent).toContain('No draft jobs found');
69+
}));
70+
71+
it('should reload data when time period changes', fakeAsync(() => {
72+
const env = new TestEnvironment();
73+
env.wait();
74+
env.component.daysBack = 14;
75+
env.wait();
76+
verify(mockedProjectService.onlineAllEventMetricsForConstructingDraftJobs(anything(), anything(), 14)).once();
77+
}));
78+
79+
describe('associates events into jobs', () => {
80+
it('successful jobs', fakeAsync(() => {
81+
const env = new TestEnvironment();
82+
env.wait();
83+
// The Serval build id and event metric ids are written here from looking at the sample data.
84+
const servalBuildId = '68fbaaba6c00da700a86c979';
85+
86+
const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job;
87+
// Confirm test setup.
88+
expect(draftJob.status).toEqual('success');
89+
90+
expect(draftJob!.startEvent!.id).toEqual('68fbaa9d6a411b337164c6ca');
91+
expect(draftJob!.buildEvent!.id).toEqual('68fbaaba6a411b337164c6d0');
92+
expect(draftJob!.cancelEvent).toBeUndefined();
93+
expect(draftJob!.finishEvent!.id).toEqual('68fbcb3e6a411b337164c86f');
94+
}));
95+
96+
it('cancelled jobs', fakeAsync(() => {
97+
const env = new TestEnvironment();
98+
env.wait();
99+
const servalBuildId = '68fb7cfd6c00da700a863d21';
100+
101+
const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job;
102+
// Confirm test setup.
103+
expect(draftJob.status).toEqual('cancelled');
104+
105+
expect(draftJob!.startEvent!.id).toEqual('68fb7cc46a411b337164c38d');
106+
expect(draftJob!.buildEvent!.id).toEqual('68fb7cfd6a411b337164c393');
107+
expect(draftJob!.cancelEvent!.id).toEqual('68fb7d5a6a411b337164c39a');
108+
expect(draftJob!.finishEvent).toBeUndefined();
109+
}));
110+
111+
it('faulted jobs', fakeAsync(() => {
112+
const env = new TestEnvironment();
113+
env.wait();
114+
const servalBuildId = '68fa5b626c00da700a848a94';
115+
116+
const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job;
117+
// Confirm test setup.
118+
expect(draftJob.status).toEqual('failed');
119+
120+
expect(draftJob!.startEvent!.id).toEqual('68fa5b496a411b3371647957');
121+
expect(draftJob!.buildEvent!.id).toEqual('68fa5b636a411b337164795d');
122+
expect(draftJob!.cancelEvent).toBeUndefined();
123+
expect(draftJob!.finishEvent!.id).toEqual('68fa5b6d6a411b3371647961');
124+
}));
125+
126+
it('in-progress jobs', fakeAsync(() => {
127+
// This Serval job had StartPreTranslationBuildAsync and BuildProjectAsync but nothing more yet.
128+
129+
// The sample data was altered to corrupt some events to simulate an in-progress job for this test.
130+
131+
const env = new TestEnvironment();
132+
env.wait();
133+
const servalBuildId = '68fb88d16c00da700a86c6bb';
134+
135+
const draftJob: DraftJob = env.component.rows.filter(row => row.job.buildId === servalBuildId)[0].job;
136+
// Confirm test setup.
137+
expect(draftJob.status).toEqual('running');
138+
139+
expect(draftJob!.startEvent!.id).toEqual('68fb88b46a411b337164c48d');
140+
expect(draftJob!.buildEvent!.id).toEqual('68fb88d16a411b337164c493');
141+
expect(draftJob!.cancelEvent).toBeUndefined();
142+
expect(draftJob!.finishEvent).toBeUndefined();
143+
}));
144+
});
145+
146+
class TestEnvironment {
147+
readonly component: DraftJobsComponent;
148+
readonly fixture: ComponentFixture<DraftJobsComponent>;
149+
readonly realtimeService: TestRealtimeService = TestBed.inject<TestRealtimeService>(TestRealtimeService);
150+
readonly testOnlineStatusService: TestOnlineStatusService = TestBed.inject(
151+
OnlineStatusService
152+
) as TestOnlineStatusService;
153+
private readonly queryParams$ = new BehaviorSubject<any>({});
154+
155+
constructor({ hasEvents = true }: { hasEvents?: boolean } = {}) {
156+
when(mockedActivatedRoute.queryParams).thenReturn(this.queryParams$.asObservable());
157+
when(mockedAuthService.currentUserRoles).thenReturn([]);
158+
when(mockedI18nService.formatDate(anything(), anything())).thenCall((date: Date) => date.toISOString());
159+
if (hasEvents) this.setupDraftJobsData();
160+
this.fixture = TestBed.createComponent(DraftJobsComponent);
161+
this.component = this.fixture.componentInstance;
162+
this.fixture.detectChanges();
163+
}
164+
165+
get table(): DebugElement | null {
166+
return this.fixture.debugElement.query(By.css('#draft-jobs-table'));
167+
}
168+
169+
get rows(): DebugElement[] {
170+
if (!this.table) return [];
171+
return this.table.queryAll(By.css('.job-row'));
172+
}
173+
174+
get emptyMessage(): DebugElement | null {
175+
return this.fixture.debugElement.query(By.css('#no-draft-jobs-found-message'));
176+
}
177+
178+
wait(): void {
179+
flush();
180+
this.fixture.detectChanges();
181+
}
182+
183+
setupDraftJobsData(): void {
184+
const eventMetrics: EventMetric[] = this.transformJsonToEventMetrics(sampleEvents);
185+
186+
when(
187+
mockedProjectService.onlineAllEventMetricsForConstructingDraftJobs(anything(), anything(), anything())
188+
).thenCall((eventTypes: string[], projectId?: string, _daysBack?: number) => {
189+
let events: EventMetric[] = eventMetrics.filter(event => eventTypes.includes(event.eventType));
190+
if (projectId != null) events = events.filter(event => event.projectId === projectId);
191+
return { results: events, unpagedCount: events.length };
192+
});
193+
194+
// Mock project names via ServalAdministrationService for projects in JSON
195+
const projectIds = [...new Set(eventMetrics.map(e => e.projectId).filter((id): id is string => id != null))];
196+
projectIds.forEach(projectId => {
197+
when(mockedServalAdministrationService.get(projectId)).thenResolve({
198+
id: projectId,
199+
data: { name: `Project ${projectId.substring(0, 8)}`, shortName: projectId.substring(0, 4) }
200+
} as any);
201+
});
202+
}
203+
204+
/**
205+
* Transforms JSON event data to EventMetric objects.
206+
* Transforms "timeStamp":{"$date":"foo"} to just "timeStamp".
207+
*/
208+
private transformJsonToEventMetrics(jsonData: any[]): EventMetric[] {
209+
return jsonData.map(event => ({
210+
id: event._id,
211+
eventType: event.eventType,
212+
timeStamp: typeof event.timeStamp === 'string' ? event.timeStamp : (event.timeStamp?.$date ?? ''),
213+
scope: event.scope as EventScope,
214+
payload: event.payload ?? {},
215+
userId: event.userId,
216+
projectId: event.projectId,
217+
result: event.result,
218+
executionTime: event.executionTime,
219+
exception: event.exception
220+
}));
221+
}
222+
}
223+
});

src/SIL.XForge.Scripture/ClientApp/src/app/serval-administration/draft-jobs.component.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ interface ProjectBooks {
2828
books: string[];
2929
}
3030

31-
interface DraftJob {
31+
export interface DraftJob {
32+
/** Serval build ID */
3233
buildId: string | null;
3334
projectId: string;
3435
startEvent?: EventMetric; // Made optional since incomplete jobs might not have a start event
@@ -67,6 +68,7 @@ const DRAFTING_EVENTS = [
6768
'BuildProjectAsync',
6869
'RetrievePreTranslationStatusAsync',
6970
'ExecuteWebhookAsync',
71+
'BuildCompletedAsync',
7072
'CancelPreTranslationBuildAsync'
7173
];
7274

@@ -284,11 +286,13 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit {
284286

285287
// Step 3: Find the first completion event after the build
286288
const candidateCompletionEvents = this.draftEvents.filter(event => {
287-
if (event.projectId !== buildEvent.projectId) return false;
289+
if (event.projectId !== buildEvent.projectId && event.payload.sfProjectId !== buildEvent.projectId)
290+
return false;
288291
if (new Date(event.timeStamp) <= buildTime) return false;
289292

290293
// Check if it's a completion event type
291294
if (
295+
event.eventType === 'BuildCompletedAsync' ||
292296
event.eventType === 'RetrievePreTranslationStatusAsync' ||
293297
event.eventType === 'ExecuteWebhookAsync' ||
294298
event.eventType === 'CancelPreTranslationBuildAsync'
@@ -423,6 +427,8 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit {
423427
if (job.finishEvent.exception != null) {
424428
status = 'failed';
425429
errorMessage = job.finishEvent.exception;
430+
} else if (job.finishEvent.payload?.buildState === 'Faulted') {
431+
status = 'failed';
426432
} else {
427433
status = 'success';
428434
}

0 commit comments

Comments
 (0)