Skip to content

Commit 85e5e48

Browse files
committed
serval admin: 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 e625975 commit 85e5e48

File tree

4 files changed

+622
-4
lines changed

4 files changed

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

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ interface ProjectBooks {
4545
books: string[];
4646
}
4747

48-
interface DraftJob {
48+
/** Defines information about a Serval draft generation request. This is exported so it can be used in tests. */
49+
export interface DraftJob {
50+
/** Serval build ID */
4951
buildId: string | null;
5052
projectId: string;
5153
startEvent?: EventMetric; // Made optional since incomplete jobs might not have a start event
@@ -84,6 +86,7 @@ const DRAFTING_EVENTS = [
8486
'BuildProjectAsync',
8587
'RetrievePreTranslationStatusAsync',
8688
'ExecuteWebhookAsync',
89+
'BuildCompletedAsync',
8790
'CancelPreTranslationBuildAsync'
8891
];
8992

@@ -322,11 +325,13 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit {
322325

323326
// Step 3: Find the first completion event after the build
324327
const candidateCompletionEvents = this.draftEvents.filter(event => {
325-
if (event.projectId !== buildEvent.projectId) return false;
328+
if (event.projectId !== buildEvent.projectId && event.payload.sfProjectId !== buildEvent.projectId)
329+
return false;
326330
if (new Date(event.timeStamp) <= buildTime) return false;
327331

328332
// Check if it's a completion event type
329333
if (
334+
event.eventType === 'BuildCompletedAsync' ||
330335
event.eventType === 'RetrievePreTranslationStatusAsync' ||
331336
event.eventType === 'ExecuteWebhookAsync' ||
332337
event.eventType === 'CancelPreTranslationBuildAsync'
@@ -461,6 +466,10 @@ export class DraftJobsComponent extends DataLoadingComponent implements OnInit {
461466
if (job.finishEvent.exception != null) {
462467
status = 'failed';
463468
errorMessage = job.finishEvent.exception;
469+
} else if (job.finishEvent.payload?.buildState === 'Faulted') {
470+
// We might expect the buildState to match BuildStates.Faulted, but the EventMetric object uses TitleCase rather
471+
// than the all caps of BuildStates.
472+
status = 'failed';
464473
} else {
465474
status = 'success';
466475
}

0 commit comments

Comments
 (0)