Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,18 @@
"sealCompliantNoReport": "No validation report is available for this feed's latest dataset.",
"sealCompliantNoData": "This feed's compliance has not been evaluated yet.",
"sealCompliantViewReport": "View latest validation report",
"sealComplianceErrorsAsOf": "Errors as of {date}",
"sealComplianceErrorsCurrent": "Current errors",
"sealComplianceValidatedOn": "Validated {date}",
"sealComplianceValidated": "Latest validation",
"sealComplianceDownloadDataset": "Download this dataset",
"sealComplianceNewBadge": "New",
"sealComplianceCarriedSummary": "{count, plural, one {# error is} other {# errors are}} carried from earlier datasets.",
"sealComplianceNewSummary": "{count, plural, one {# error is} other {# errors are}} new in this dataset.",
"sealComplianceTruncated": "Showing the {shown} most frequent of {total} errors. <link>See the full validation report</link>.",
"sealComplianceViewReportLink": "Validation report",
"sealComplianceOccurrences": "{count, plural, one {# time} other {# times}}",
"sealComplianceHistoryError": "Earlier datasets could not be loaded, so new errors are not marked.",
"pageGeneratedAt": "Page generated at",
"serviceDateRange": "Service Date Range",
"serviceDateRangeTooltip": "Dates are relative to the specified timezone. If no timezone is specified, the dates are in UTC.",
Expand Down
12 changes: 12 additions & 0 deletions messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,18 @@
"sealCompliantNoReport": "Aucun rapport de validation n'est disponible pour le dernier jeu de données de ce flux.",
"sealCompliantNoData": "La conformité de ce flux n'a pas encore été évaluée.",
"sealCompliantViewReport": "Voir le dernier rapport de validation",
"sealComplianceErrorsAsOf": "Erreurs au {date}",
"sealComplianceErrorsCurrent": "Erreurs actuelles",
"sealComplianceValidatedOn": "Validé le {date}",
"sealComplianceValidated": "Dernière validation",
"sealComplianceDownloadDataset": "Télécharger ce jeu de données",
"sealComplianceNewBadge": "Nouveau",
"sealComplianceCarriedSummary": "{count, plural, one {# erreur est reprise} other {# erreurs sont reprises}} de jeux de données antérieurs.",
"sealComplianceNewSummary": "{count, plural, one {# erreur est nouvelle} other {# erreurs sont nouvelles}} dans ce jeu de données.",
"sealComplianceTruncated": "Affichage des {shown} erreurs les plus fréquentes sur {total}. <link>Voir le rapport de validation complet</link>.",
"sealComplianceViewReportLink": "Rapport de validation",
"sealComplianceOccurrences": "{count, plural, one {# fois} other {# fois}}",
"sealComplianceHistoryError": "Les jeux de données antérieurs n'ont pas pu être chargés; les nouvelles erreurs ne sont donc pas signalées.",
"pageGeneratedAt": "Page generated at",
"serviceDateRange": "Service Date Range",
"serviceDateRangeTooltip": "Dates are relative to the specified timezone. If no timezone is specified, the dates are in UTC.",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,17 @@ import {
AVAILABILITY_LIMIT,
AVAILABILITY_MAX_EXTRA_PAGES,
SEAL_ANALYSIS_REVALIDATE,
VALIDATION_REPORTS_LIMIT,
fetchGuestSealAnalysisData,
} from './seal-analysis-data';

jest.mock('server-only', () => ({}));

const mockGetValidatorRules = jest.fn();
jest.mock('../../../../../screens/Feed/lib/validator-rules', () => ({
getValidatorRules: async () => await mockGetValidatorRules(),
}));

// Pass-throughs so the real fetcher body runs. `cache` is stubbed because
// React's request-scoped memoization has no scope in a bare node test.
jest.mock('react', () => ({
Expand All @@ -29,6 +35,7 @@ jest.mock('next/cache', () => ({
const mockGetGtfsFeedReliability = jest.fn();
const mockGetGtfsFeedAvailability = jest.fn();
const mockGetGtfsFeedContinuousCoverage = jest.fn();
const mockGetGtfsFeedValidationReports = jest.fn();

jest.mock('../../../../../services/feeds', () => ({
getGtfsFeedReliability: (...args: unknown[]) =>
Expand All @@ -37,6 +44,8 @@ jest.mock('../../../../../services/feeds', () => ({
mockGetGtfsFeedAvailability(...args),
getGtfsFeedContinuousCoverage: (...args: unknown[]) =>
mockGetGtfsFeedContinuousCoverage(...args),
getGtfsFeedValidationReports: (...args: unknown[]) =>
mockGetGtfsFeedValidationReports(...args),
}));

jest.mock('../../../../../utils/auth-server', () => ({
Expand All @@ -58,24 +67,54 @@ const availability = {
// came back rather than the page size it asked for.
const flattenedAvailability = { ...availability, offset: 0, limit: 1 };
const coverage = { feed_id: 'mdb-1', latest_files: [] };
const validatorRules = {
invalid_color: { summary: 'A color is invalid.', files: ['routes.txt'] },
};
const validationReports = {
feed_id: 'mdb-1',
total: 0,
offset: 0,
limit: VALIDATION_REPORTS_LIMIT,
items: [],
};

describe('fetchGuestSealAnalysisData', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetGtfsFeedReliability.mockResolvedValue(report);
mockGetGtfsFeedAvailability.mockResolvedValue(availability);
mockGetGtfsFeedContinuousCoverage.mockResolvedValue(coverage);
mockGetGtfsFeedValidationReports.mockResolvedValue(validationReports);
mockGetValidatorRules.mockResolvedValue(validatorRules);
});

it('fetches the validator rules alongside the endpoints, not after them', async () => {
await fetchGuestSealAnalysisData('gtfs', 'mdb-1');

expect(mockGetValidatorRules).toHaveBeenCalledTimes(1);
});

it('degrades to an empty rule index when that fetch fails', async () => {
mockGetValidatorRules.mockRejectedValue(new Error('boom'));

const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1');

expect(result?.validatorRules).toEqual({});
expect(result?.reliability).toEqual(report);
});

it('returns all three payloads on success', async () => {
it('returns all four payloads on success', async () => {
const result = await fetchGuestSealAnalysisData('gtfs', 'mdb-1');

expect(result).toEqual({
reliability: report,
availability: flattenedAvailability,
continuousCoverage: coverage,
validationReports,
validatorRules,
reliabilityError: false,
availabilityError: false,
validationReportsError: false,
});
});

Expand All @@ -84,11 +123,12 @@ describe('fetchGuestSealAnalysisData', () => {

expect(SEAL_ANALYSIS_REVALIDATE).toBe(21600);
// Keys exclude the caller so guest and authed share the same entries.
expect(mockUnstableCache).toHaveBeenCalledTimes(3);
expect(mockUnstableCache).toHaveBeenCalledTimes(4);
for (const key of [
'seal-analysis-reliability-mdb-1',
'seal-analysis-availability-mdb-1',
'seal-analysis-coverage-mdb-1',
'seal-analysis-validation-reports-mdb-1',
]) {
expect(mockUnstableCache).toHaveBeenCalledWith(
[key],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
getGtfsFeedAvailability,
getGtfsFeedContinuousCoverage,
getGtfsFeedReliability,
getGtfsFeedValidationReports,
} from '../../../../../services/feeds';
import type { components } from '../../../../../services/feeds/types';
import {
Expand All @@ -21,19 +22,29 @@ import {
getUserContextJwtFromCookie,
} from '../../../../../utils/auth-server';
import { subMonthsUtc } from '../../../../../utils/date';
import { getValidatorRules } from '../../../../../screens/Feed/lib/validator-rules';
import { type ValidatorRuleInfo } from '../../../../../screens/Feed/lib/validation-notices';

type ReliabilityReport = components['schemas']['FeedReliabilityReport'];
type AvailabilityResponse =
components['schemas']['GtfsFeedAvailabilityResponse'];
type ContinuousCoverageResponse =
components['schemas']['GtfsFeedContinuousCoverageResponse'];
type ValidationReportsResponse =
components['schemas']['GtfsFeedValidationReportsResponse'];

/**
* 6 hours
*/
export const SEAL_ANALYSIS_REVALIDATE = 21600;

const COVERAGE_LIMIT = 100;
/**
* How many past datasets the Compliant criterion shows alongside the latest
* one. The endpoint caps `limit` at 100; 20 is a couple of months of daily
* datasets, which is as far back as the history panel reads.
*/
export const VALIDATION_REPORTS_LIMIT = 20;
/** Exported so the specs follow it rather than restating the page size. */
export const AVAILABILITY_LIMIT = 200;

Expand Down Expand Up @@ -119,12 +130,16 @@ export interface SealAnalysisData {
reliability?: ReliabilityReport;
availability?: AvailabilityResponse;
continuousCoverage?: ContinuousCoverageResponse;
validationReports?: ValidationReportsResponse;
/** Validator rule index, joined onto the notice codes at render time. */
validatorRules: Record<string, ValidatorRuleInfo>;
/**
* True when the reliability call failed outright - distinct from "this feed
* has no verdict yet", which comes back as a successful response.
*/
reliabilityError: boolean;
availabilityError: boolean;
validationReportsError: boolean;
}

/**
Expand Down Expand Up @@ -198,7 +213,35 @@ function cachedContinuousCoverage(
}

/**
* Fetch the three seal endpoints together.
* The validation history, unfiltered.
*
* No `severity` is sent: the panel filters the notices it already holds, so
* one cached response serves every filter the reader picks rather than a
* round trip per toggle.
*/
function cachedValidationReports(
feedId: string,
accessToken: string,
userContextJwt: string | undefined,
): () => Promise<ValidationReportsResponse | undefined> {
return unstable_cache(
async () =>
await getGtfsFeedValidationReports(
feedId,
accessToken,
{ limit: VALIDATION_REPORTS_LIMIT },
userContextJwt,
),
[`seal-analysis-validation-reports-${feedId}`],
{
tags: [`feed-${feedId}`, 'seal-analysis'],
revalidate: SEAL_ANALYSIS_REVALIDATE,
},
);
}

/**
* Fetch the four seal endpoints and the validator rule index together.
*
* `allSettled`, not `all`: the availability and continuous-coverage history
* are supporting detail, so one of them failing degrades to `undefined` (with
Expand All @@ -213,12 +256,21 @@ async function fetchSealAnalysisImpl(
accessToken: string,
userContextJwt: string | undefined,
): Promise<SealAnalysisData> {
const [reliabilityResult, availabilityResult, coverageResult] =
await Promise.allSettled([
cachedReliability(feedId, accessToken, userContextJwt)(),
cachedAvailability(feedId, accessToken, userContextJwt)(),
cachedContinuousCoverage(feedId, accessToken, userContextJwt)(),
]);
const [
reliabilityResult,
availabilityResult,
coverageResult,
validationReportsResult,
validatorRulesResult,
] = await Promise.allSettled([
cachedReliability(feedId, accessToken, userContextJwt)(),
cachedAvailability(feedId, accessToken, userContextJwt)(),
cachedContinuousCoverage(feedId, accessToken, userContextJwt)(),
cachedValidationReports(feedId, accessToken, userContextJwt)(),
// Fetched here rather than at render time so it runs alongside the
// endpoints above instead of after them.
getValidatorRules(),
]);

return {
reliability:
Expand All @@ -233,6 +285,15 @@ async function fetchSealAnalysisImpl(
availabilityError: availabilityResult.status === 'rejected',
continuousCoverage:
coverageResult.status === 'fulfilled' ? coverageResult.value : undefined,
validationReports:
validationReportsResult.status === 'fulfilled'
? validationReportsResult.value
: undefined,
validationReportsError: validationReportsResult.status === 'rejected',
validatorRules:
validatorRulesResult.status === 'fulfilled'
? validatorRulesResult.value
: {},
};
}

Expand Down
68 changes: 45 additions & 23 deletions src/app/screens/Feed/components/ComplianceCriterionBody.tsx
Original file line number Diff line number Diff line change
@@ -1,40 +1,67 @@
import * as React from 'react';
import { Box, Button, Typography } from '@mui/material';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import { Alert, Box, Typography } from '@mui/material';
import { getTranslations } from 'next-intl/server';
import CriterionGraceCountdown from './CriterionGraceCountdown';
import ValidationErrorsPanel from './ValidationErrorsPanel';
import { getComplianceSummary } from '../lib/compliance-report';
import {
type ValidatorRuleInfo,
buildValidationErrorsModel,
} from '../lib/validation-notices';
import { buildDatasetDownloadUrl } from '../../../services/feeds';
import { type components } from '../../../services/feeds/types';

type ReliabilityCriterion = components['schemas']['ReliabilityCriterion'];
type ValidationReport = components['schemas']['ValidationReport'];
type ValidationReportsResponse =
components['schemas']['GtfsFeedValidationReportsResponse'];

export interface ComplianceCriterionBodyProps {
criterion: ReliabilityCriterion;
/** Validation report of the feed's latest dataset, when it has one. */
report?: ValidationReport;
/** Feed id, used to build the dataset download URL. */
feedId?: string;
/** Validation history of the feed, one entry per dataset. */
validationReports?: ValidationReportsResponse;
validationReportsError?: boolean;
/** Validator rule index, fetched with the seal endpoints. */
validatorRules?: Record<string, ValidatorRuleInfo>;
/** Pinned by the page so every date-derived branch agrees. */
now: Date;
}

/**
* Body of the Compliant criterion: what the latest dataset's validation
* report says, the 30-day countdown while an error is still inside its grace
* period, and a way through to the report itself.
* period, and the errors behind the verdict.
*/
export default async function ComplianceCriterionBody({
criterion,
report,
feedId,
validationReports,
validationReportsError = false,
validatorRules,
now,
}: ComplianceCriterionBodyProps): Promise<React.ReactElement> {
const t = await getTranslations('feeds');
const summary = getComplianceSummary(criterion, report, now);
const reportUrl = report?.url_html;

const model = buildValidationErrorsModel(validationReports, validatorRules);

// The criterion counts distinct codes, not occurrences, so the sentence
// and the list below it agree.
const summary = getComplianceSummary(criterion, report, now, {
fallbackErrorCount: model.totalCount,
});

const downloadUrl =
feedId != undefined && feedId.length > 0 && model.datasetId != undefined
? buildDatasetDownloadUrl(feedId, model.datasetId)
: undefined;

return (
<Box data-testid='compliance-criterion-body'>
{/* Bold headline then detail, matching the shape Official and Stable
get from the shared criterion copy. */}
<Typography variant='body1' sx={{ fontWeight: 700 }}>
{t(summary.subtitleKey)}
</Typography>
Expand All @@ -44,27 +71,22 @@ export default async function ComplianceCriterionBody({

{summary.graceDaysLeft != undefined && (
<CriterionGraceCountdown
title={t('sealCompliantGraceTitle', {
days: summary.graceDaysLeft,
})}
title={t('sealCompliantGraceTitle', { days: summary.graceDaysLeft })}
description={t('sealCompliantGraceDescription')}
/>
)}

{reportUrl != undefined && reportUrl.length > 0 && (
<Box sx={{ display: 'flex', justifyContent: 'flex-end', mt: 2 }}>
<Button
href={reportUrl}
target='_blank'
rel='noreferrer'
variant='text'
sx={{ display: 'inline-flex', alignItems: 'center', gap: 0.5 }}
endIcon={<OpenInNewIcon></OpenInNewIcon>}
>
{t('sealCompliantViewReport')}
</Button>
</Box>
{validationReportsError && (
<Alert
severity='warning'
sx={{ mt: 2 }}
data-testid='validation-errors-error'
>
{t('sealComplianceHistoryError')}
</Alert>
)}

<ValidationErrorsPanel model={model} downloadUrl={downloadUrl} />
</Box>
);
}
Loading
Loading