Added links to EU Supply tenders, as there are to Find a Tender notices
This commit is contained in:
1 parent
3a06132071
commit
64886a2f1e
5 files changed
+143
-50
No files matched your search
@@ -2,10 +2,11 @@ import { expect, test } from 'vitest';
|
||||
|
||||
import type { OpportunityRelease } from '@/types/Opportunities';
|
||||
import {
|
||||
euSupplyTenderUrl,
|
||||
findATenderNoticeUrl,
|
||||
isFromFindATender,
|
||||
latestRelease,
|
||||
mergeOpportunities,
|
||||
noticeSite,
|
||||
} from './opportunities';
|
||||
import { testOpportunity } from './testOpportunity';
|
||||
|
||||
@@ -31,9 +32,11 @@ const release = (releaseId: string, releaseDate: string): OpportunityRelease =>
|
||||
data: '{}',
|
||||
});
|
||||
|
||||
test('only Find a Tender opportunities are from Find a Tender', () => {
|
||||
expect(isFromFindATender(testOpportunity('a'))).toBe(true);
|
||||
expect(isFromFindATender(testOpportunity('a', { source: 'FTSMock' }))).toBe(false);
|
||||
test('Find a Tender and EU Supply opportunities have a notice site, others none', () => {
|
||||
expect(noticeSite(testOpportunity('a'))?.name).toBe('Find a Tender');
|
||||
expect(noticeSite(testOpportunity('a', { source: 'EU Supply - UK' }))?.name).toBe('EU Supply');
|
||||
expect(noticeSite(testOpportunity('a', { source: 'FTSMock' }))).toBeUndefined();
|
||||
expect(noticeSite(testOpportunity('a', { source: '' }))).toBeUndefined();
|
||||
});
|
||||
|
||||
test('the latest release is the newest, wherever it is in the list', () => {
|
||||
@@ -57,3 +60,39 @@ test('a notice’s URL is its release ID on Find a Tender', () => {
|
||||
'https://www.find-tender.service.gov.uk/notice/095868-2026',
|
||||
);
|
||||
});
|
||||
|
||||
// As the backend stores an EU Supply tender, with the & escaped as Go does.
|
||||
const EU_SUPPLY_URL =
|
||||
'https://uk.eu-supply.com/ctm/Supplier/PublicPurchase/107679/0/0?returnUrl=ctm/Supplier/PublicTenders&b=UK';
|
||||
const euSupplyData = JSON.stringify({ id: '100112759', pid: '107679', url: EU_SUPPLY_URL }).replace(
|
||||
'&',
|
||||
'\\u0026',
|
||||
);
|
||||
|
||||
test('an EU Supply tender’s page is the url in its release, unchanged', () => {
|
||||
expect(euSupplyTenderUrl(euSupplyData)).toBe(EU_SUPPLY_URL);
|
||||
const site = noticeSite(testOpportunity('a', { source: 'EU Supply - UK' }));
|
||||
expect(
|
||||
site?.urlFor({ ...release('100112759', '2026-08-04T15:31:00Z'), data: euSupplyData }),
|
||||
).toBe(EU_SUPPLY_URL);
|
||||
// Not normalised, as new URL(...).href would (it adds the trailing slash).
|
||||
expect(euSupplyTenderUrl('{"url":"https://uk.eu-supply.com"}')).toBe('https://uk.eu-supply.com');
|
||||
});
|
||||
|
||||
test.each<{ name: string; data: string }>([
|
||||
{ name: 'no url', data: '{"id":"1"}' },
|
||||
{ name: 'a url that isn’t text', data: '{"url":42}' },
|
||||
{ name: 'a relative url', data: '{"url":"/ctm/Supplier/PublicPurchase/1"}' },
|
||||
{ name: 'a javascript: url', data: '{"url":"javascript:alert(1)"}' },
|
||||
{ name: 'data that isn’t an object', data: '"https://uk.eu-supply.com/"' },
|
||||
{ name: 'data that isn’t JSON', data: 'not json' },
|
||||
])('an EU Supply release with $name has no page', ({ data }) => {
|
||||
expect(euSupplyTenderUrl(data)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('a Find a Tender release’s page is its notice', () => {
|
||||
const site = noticeSite(testOpportunity('a'));
|
||||
expect(site?.urlFor(release('095868-2026', '2026-10-01T09:00:00Z'))).toBe(
|
||||
'https://www.find-tender.service.gov.uk/notice/095868-2026',
|
||||
);
|
||||
});
|
||||
@@ -13,14 +13,6 @@ export function mergeOpportunities(
|
||||
return [...byUuid.values()];
|
||||
}
|
||||
|
||||
/** The source the backend gives opportunities fetched from Find a Tender. */
|
||||
const FIND_A_TENDER_SOURCE = 'FindATender';
|
||||
|
||||
/** Whether the opportunity was fetched from Find a Tender, so its releases are notices there. */
|
||||
export function isFromFindATender(opportunity: Opportunity): boolean {
|
||||
return opportunity.source === FIND_A_TENDER_SOURCE;
|
||||
}
|
||||
|
||||
/** The opportunity's newest release, or undefined if it has none. */
|
||||
export function latestRelease(opportunity: Opportunity): OpportunityRelease | undefined {
|
||||
let latest: OpportunityRelease | undefined;
|
||||
@@ -36,3 +28,58 @@ export function latestRelease(opportunity: Opportunity): OpportunityRelease | un
|
||||
export function findATenderNoticeUrl(releaseId: string): string {
|
||||
return `https://www.find-tender.service.gov.uk/notice/${encodeURIComponent(releaseId)}`;
|
||||
}
|
||||
|
||||
// EU Supply's releases are the portal's tenders, as the backend stores them
|
||||
// (internal/eusupply's Tender), with the address of the tender's page.
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === 'object' && value !== null;
|
||||
|
||||
const isWebAddress = (address: string): boolean => {
|
||||
try {
|
||||
const { protocol } = new URL(address);
|
||||
return protocol === 'https:' || protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* The page of an EU Supply tender: the url in its release's data, as it is,
|
||||
* or undefined if there's none. Only http and https addresses are used, so
|
||||
* a malformed or javascript: one is never opened.
|
||||
*/
|
||||
export function euSupplyTenderUrl(data: string): string | undefined {
|
||||
let tender: unknown;
|
||||
try {
|
||||
tender = JSON.parse(data);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (!isRecord(tender) || typeof tender.url !== 'string') return undefined;
|
||||
return isWebAddress(tender.url) ? tender.url : undefined;
|
||||
}
|
||||
|
||||
/** A site an opportunity's releases can be opened on. */
|
||||
export interface NoticeSite {
|
||||
/** The site's name, for labels: "Open the latest notice on <name>". */
|
||||
name: string;
|
||||
/** The page of release on the site, or undefined if it has none. */
|
||||
urlFor: (release: OpportunityRelease) => string | undefined;
|
||||
}
|
||||
|
||||
// By the source the backend gives the opportunities fetched from each site.
|
||||
const NOTICE_SITES: Record<string, NoticeSite> = {
|
||||
FindATender: {
|
||||
name: 'Find a Tender',
|
||||
urlFor: (release) => findATenderNoticeUrl(release.release_id),
|
||||
},
|
||||
'EU Supply - UK': {
|
||||
name: 'EU Supply',
|
||||
urlFor: (release) => euSupplyTenderUrl(release.data),
|
||||
},
|
||||
};
|
||||
|
||||
/** The site the opportunity's releases can be opened on, or undefined for sources without one. */
|
||||
export function noticeSite(opportunity: Opportunity): NoticeSite | undefined {
|
||||
return NOTICE_SITES[opportunity.source];
|
||||
}
|
||||
Reference in new issue
Block a user