155 lines
7.0 KiB
JavaScript
155 lines
7.0 KiB
JavaScript
// Read-only production acceptance probe for five current laboratory tenders.
|
||
// It records every D10 gate separately and never turns an upstream blocker into
|
||
// a positive result. No authentication, writes, payments, messages or bids.
|
||
import { createHash } from 'node:crypto';
|
||
import { mkdir, writeFile } from 'node:fs/promises';
|
||
import { dirname } from 'node:path';
|
||
import { currentLabAcceptanceCases } from './fixtures/d10-current-lab.mjs';
|
||
|
||
const base = String(process.argv[2] || 'https://ihaleist.com').replace(/\/$/, '');
|
||
const evidencePath = process.env.IHALEIST_LIVE_FIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-five-tenders.json';
|
||
const timeoutMs = 30_000;
|
||
|
||
function normalize(value) {
|
||
return String(value || '').normalize('NFKC').replace(/\s+/g, ' ').trim().toLocaleLowerCase('tr-TR');
|
||
}
|
||
|
||
function includesTerm(text, term) {
|
||
return normalize(text).includes(normalize(term));
|
||
}
|
||
|
||
async function probeJson(path) {
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||
try {
|
||
const response = await fetch(`${base}${path}`, { signal: controller.signal, headers: { 'User-Agent': 'ihaleist-live-five-probe/1.0' } });
|
||
const data = await response.json().catch(() => null);
|
||
return {
|
||
path,
|
||
status: response.status,
|
||
ok: response.ok && data?.success === true,
|
||
data,
|
||
error: response.ok && data?.success === true ? null : data?.error || `HTTP ${response.status}`,
|
||
};
|
||
} catch (error) {
|
||
return { path, status: 0, ok: false, data: null, error: error?.message || String(error) };
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
}
|
||
|
||
const listProbe = await probeJson('/api/tenders');
|
||
const list = listProbe.data || {};
|
||
const byIkn = new Map((Array.isArray(list.tenders) ? list.tenders : []).map((tender) => [String(tender.ikn || '').replace(/\s/g, ''), tender]));
|
||
const tenders = [];
|
||
for (const fixture of currentLabAcceptanceCases) {
|
||
const listed = byIkn.get(fixture.ikn) || null;
|
||
const detailProbe = await probeJson(`/api/tenders/${encodeURIComponent(fixture.id)}/detail`);
|
||
const detail = detailProbe.data || {};
|
||
const announcementText = String(detail.aciklama || '');
|
||
const requirementChecks = fixture.requirements.map(({ key, label, term }) => ({
|
||
key,
|
||
label,
|
||
sourceTerm: term,
|
||
present: includesTerm(announcementText, term),
|
||
}));
|
||
const announcementChecks = {
|
||
listed: Boolean(listed),
|
||
detailEndpoint: detailProbe.ok,
|
||
ikn: includesTerm(announcementText, fixture.ikn),
|
||
title: includesTerm(`${listed?.baslik || ''} ${announcementText}`, fixture.titleTerm),
|
||
date: detail.tarih === fixture.date && includesTerm(announcementText, fixture.date),
|
||
time: detail.saat === fixture.time && includesTerm(announcementText, fixture.time),
|
||
nature: includesTerm(announcementText, fixture.natureTerm),
|
||
delivery: includesTerm(announcementText, fixture.deliveryTerm),
|
||
validity: includesTerm(announcementText, `geçerlilik süresi, ihale tarihinden itibaren ${fixture.validityDays}`),
|
||
partialOffer: includesTerm(announcementText, fixture.partialOfferTerm),
|
||
requirements: requirementChecks.every(({ present }) => present),
|
||
};
|
||
const announcementPassed = Object.values(announcementChecks).every(Boolean);
|
||
|
||
const documentProbe = await probeJson(`/api/documents/ilan?ikn=${encodeURIComponent(fixture.ikn)}`);
|
||
const document = documentProbe.data || {};
|
||
const officialDocumentPackagePassed = documentProbe.ok
|
||
&& document.ikn === fixture.ikn
|
||
&& Number(document.dokumanSayisi) > 0;
|
||
const documentBlocker = officialDocumentPackagePassed ? null : {
|
||
stage: 'officialDocumentPackage',
|
||
status: documentProbe.status,
|
||
code: document.code || null,
|
||
retryable: document.retryable ?? null,
|
||
message: document.error || documentProbe.error,
|
||
};
|
||
const dependentStatus = officialDocumentPackagePassed ? 'not-run-read-only-scope' : 'blocked-by-official-document-package';
|
||
const stages = {
|
||
selectionAndAnnouncement: { passed: announcementPassed, status: announcementPassed ? 'passed' : 'failed' },
|
||
officialDocumentPackage: { passed: officialDocumentPackagePassed, status: officialDocumentPackagePassed ? 'passed' : 'blocked', blocker: documentBlocker },
|
||
productMatch: { passed: false, status: dependentStatus },
|
||
companyDocumentEvidence: { passed: false, status: dependentStatus },
|
||
priceAndOutputs: { passed: false, status: dependentStatus },
|
||
};
|
||
tenders.push({
|
||
ikn: fixture.ikn,
|
||
scenario: fixture.scenario,
|
||
id: fixture.id,
|
||
title: listed?.baslik || detail.title || null,
|
||
institution: listed?.kurum || detail.institution || null,
|
||
city: listed?.sehir || detail.city || fixture.city,
|
||
tenderDate: detail.tarih || null,
|
||
tenderTime: detail.saat || null,
|
||
sourceUrl: listed?.kaynakUrl || null,
|
||
publicAnnouncement: {
|
||
endpointStatus: detailProbe.status,
|
||
textChars: announcementText.length,
|
||
normalizedTextSha256: announcementText
|
||
? createHash('sha256').update(normalize(announcementText)).digest('hex')
|
||
: null,
|
||
checks: announcementChecks,
|
||
requirementChecks,
|
||
},
|
||
officialDocumentProbe: {
|
||
endpointStatus: documentProbe.status,
|
||
code: document.code || null,
|
||
retryable: document.retryable ?? null,
|
||
documentCount: Number(document.dokumanSayisi) || 0,
|
||
announcementCount: Number(document.ilanSayisi) || 0,
|
||
okasCount: Number(document.okasSayisi) || 0,
|
||
error: document.error || documentProbe.error,
|
||
},
|
||
stages,
|
||
positive: Object.values(stages).every(({ passed }) => passed),
|
||
});
|
||
}
|
||
|
||
const blockers = tenders.flatMap((tender) => tender.stages.officialDocumentPackage.blocker
|
||
? [{ ikn: tender.ikn, ...tender.stages.officialDocumentPackage.blocker }]
|
||
: []);
|
||
const result = {
|
||
checkedAt: new Date().toISOString(),
|
||
base,
|
||
readOnly: true,
|
||
listProbe: { status: listProbe.status, success: listProbe.ok },
|
||
listCount: Array.isArray(list.tenders) ? list.tenders.length : 0,
|
||
selectedCount: tenders.length,
|
||
announcementPassCount: tenders.filter(({ stages }) => stages.selectionAndAnnouncement.passed).length,
|
||
officialDocumentPackagePassCount: tenders.filter(({ stages }) => stages.officialDocumentPackage.passed).length,
|
||
positiveCount: tenders.filter(({ positive }) => positive).length,
|
||
pass: tenders.length === 5 && tenders.every(({ positive }) => positive),
|
||
blockers,
|
||
tenders,
|
||
scope: 'Canlı liste, ilan metni ve resmi doküman uçları salt okunur denetlendi; giriş, veri değişikliği, ödeme, mesaj veya teklif gönderimi yapılmadı.',
|
||
};
|
||
await mkdir(dirname(evidencePath), { recursive: true });
|
||
await writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`);
|
||
console.log(JSON.stringify({
|
||
base,
|
||
checkedAt: result.checkedAt,
|
||
selectedCount: result.selectedCount,
|
||
announcementPassCount: result.announcementPassCount,
|
||
officialDocumentPackagePassCount: result.officialDocumentPackagePassCount,
|
||
positiveCount: result.positiveCount,
|
||
pass: result.pass,
|
||
blockerCodes: [...new Set(blockers.map(({ code }) => code).filter(Boolean))],
|
||
}));
|
||
if (!result.pass) process.exitCode = 2;
|