fix: guide EKAP human verification
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { currentLabAcceptanceCases as cases } from './fixtures/d10-current-lab.mjs';
|
||||
|
||||
test('D10 current laboratory acceptance fixture is complete and explicit', () => {
|
||||
assert.equal(cases.length, 5);
|
||||
assert.equal(new Set(cases.map(({ ikn }) => ikn)).size, 5);
|
||||
assert.equal(new Set(cases.map(({ id }) => id)).size, 5);
|
||||
|
||||
for (const item of cases) {
|
||||
assert.match(item.ikn, /^2026\/\d{7}$/);
|
||||
assert.match(item.id, /^ilan-\d+$/);
|
||||
assert.match(item.date, /^\d{2}\.\d{2}\.2026$/);
|
||||
assert.match(item.time, /^\d{2}:\d{2}$/);
|
||||
assert.ok(item.titleTerm);
|
||||
assert.ok(item.natureTerm);
|
||||
assert.ok(item.deliveryTerm);
|
||||
assert.ok([60, 120].includes(item.validityDays));
|
||||
assert.ok(item.partialOfferTerm);
|
||||
assert.ok(item.requirements.length >= 5);
|
||||
assert.equal(new Set(item.requirements.map(({ key }) => key)).size, item.requirements.length);
|
||||
for (const requirement of item.requirements) {
|
||||
assert.ok(requirement.label);
|
||||
assert.ok(requirement.term);
|
||||
}
|
||||
}
|
||||
});
|
||||
Vendored
+117
@@ -0,0 +1,117 @@
|
||||
const commonRequirements = [
|
||||
{ key: 'offer-letter', label: 'Teklif mektubu', term: 'Teklif mektubu' },
|
||||
{ key: 'authorization', label: 'Teklif vermeye yetki bilgileri', term: 'Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler' },
|
||||
{ key: 'temporary-guarantee', label: 'Geçici teminat', term: 'Geçici teminat' },
|
||||
];
|
||||
|
||||
function tender(definition) {
|
||||
return {
|
||||
...definition,
|
||||
requirements: [...commonRequirements, ...definition.requirements],
|
||||
};
|
||||
}
|
||||
|
||||
// Current public-announcement acceptance set selected on 15 September 2026.
|
||||
// These records prove announcement requirements only. EKAP's official document
|
||||
// package, company/product evidence and generated outputs are separate D10 gates.
|
||||
export const currentLabAcceptanceCases = [
|
||||
tender({
|
||||
ikn: '2026/1634449',
|
||||
id: 'ilan-2219605',
|
||||
city: 'Rize',
|
||||
date: '05.10.2026',
|
||||
time: '10:30',
|
||||
scenario: '20-kalem-laboratuvar-cihazi',
|
||||
titleTerm: 'Laboratuvar cihazı',
|
||||
natureTerm: '20 KALEM',
|
||||
deliveryTerm: '50 (Elli) takvim günü',
|
||||
validityDays: 60,
|
||||
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||
requirements: [
|
||||
{ key: 'manufacturer-or-authorized-seller', label: 'İmalatçı/yetkili satıcı/yetkili temsilci belgesi', term: 'Yetkili Satıcı Belgesi' },
|
||||
{ key: 'service-qualification', label: 'TSE Hizmet Yeterlilik Belgesi', term: 'TSE Hizmet Yeterlilik Belgesi' },
|
||||
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||
{ key: 'brand-model', label: 'Marka-model beyanı', term: 'MARKA - MODEL BEYANI' },
|
||||
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||
],
|
||||
}),
|
||||
tender({
|
||||
ikn: '2026/1676347',
|
||||
id: 'ilan-2218485',
|
||||
city: 'Sivas',
|
||||
date: '01.10.2026',
|
||||
time: '10:30',
|
||||
scenario: 'cihaz-malzeme-yazilim',
|
||||
titleTerm: 'Laboratuvar cihaz, malzeme ve yazılım',
|
||||
natureTerm: '2 Kısım Cihaz, 1 Kısım Yazılım ve 1 Kısım Sarf Malzeme',
|
||||
deliveryTerm: '3. Kısım yazılımın kurulumu 15 takvim günü',
|
||||
validityDays: 60,
|
||||
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||
requirements: [
|
||||
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||
],
|
||||
}),
|
||||
tender({
|
||||
ikn: '2026/1667534',
|
||||
id: 'ilan-2217472',
|
||||
city: 'Konya',
|
||||
date: '28.09.2026',
|
||||
time: '10:30',
|
||||
scenario: 'yedek-parca-ve-sarf',
|
||||
titleTerm: 'Laboratuvar cihaz yedek parça ve sarf malzeme',
|
||||
natureTerm: '28 Kalem Laboratuvar Cihaz Yedek Parça ve Tıbbi Sarf Malzemesi',
|
||||
deliveryTerm: '27. ve 28. kalemler için 10 takvim günü',
|
||||
validityDays: 120,
|
||||
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||
requirements: [
|
||||
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||
{ key: 'sample', label: 'Numune', term: 'Numune sunulması istenmektedir' },
|
||||
],
|
||||
}),
|
||||
tender({
|
||||
ikn: '2026/1620320',
|
||||
id: 'ilan-2214836',
|
||||
city: 'Zonguldak',
|
||||
date: '01.10.2026',
|
||||
time: '10:00',
|
||||
scenario: '220-kalem-laboratuvar-sarfi',
|
||||
titleTerm: 'Laboratuvar sarf malzemeleri',
|
||||
natureTerm: '220 Kalem Laboratuvar Sarf Malzeme',
|
||||
deliveryTerm: '15(on beş) gün içerisinde',
|
||||
validityDays: 120,
|
||||
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||
requirements: [
|
||||
{ key: 'scope-exclusion', label: 'Kapsam Dışı Belgesi', term: 'Kapsam Dışı Belgesi' },
|
||||
{ key: 'sales-center', label: 'Satış Merkezi Yetki Belgesi', term: 'Satış Merkezi Yetki Belgesi' },
|
||||
{ key: 'uts', label: 'TİTUBB veya ÜTS belgesi', term: 'TİTUBB veya ÜTS belgesi' },
|
||||
{ key: 'sample', label: 'Numune', term: 'Numune sunulması istenmektedir' },
|
||||
],
|
||||
}),
|
||||
tender({
|
||||
ikn: '2026/1636687',
|
||||
id: 'ilan-2211682',
|
||||
city: 'Ankara',
|
||||
date: '28.09.2026',
|
||||
time: '14:00',
|
||||
scenario: 'kit-karsiligi-otoanalizor',
|
||||
titleTerm: 'Kit karşılığı otoanalizör',
|
||||
natureTerm: '24 Aylık Kit ve Kit Karşılığı Alınacak Otoanalizör',
|
||||
deliveryTerm: '15 (Onbeş) takvim günü',
|
||||
validityDays: 120,
|
||||
partialOfferTerm: 'işin tamamı için teklif verilecektir',
|
||||
requirements: [
|
||||
{ key: 'service-training', label: 'Teknik Servis Eğitim Sertifikası', term: 'Teknik Servis Eğitim Sertifikası' },
|
||||
{ key: 'brochure', label: 'Broşür', term: 'Broşür' },
|
||||
{ key: 'user-guide', label: 'Cihaz ve ODS kullanım kılavuzu', term: 'ODS yazılım sistemine ait kullanım klavuzu' },
|
||||
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||
{ key: 'sales-center', label: 'Satış Merkezi Yetki Belgesi', term: 'Satış Merkezi Yetki Belgesi' },
|
||||
{ key: 'device-id-cards', label: 'Cihaz ve sistem tanıtım kimlik kartı', term: 'tanıtım kimlik kartı' },
|
||||
{ key: 'production-and-age', label: 'Üretimde olma ve 13 yaş altı kanıtı', term: 'on üç yaşın altında olduğunu belirten belgeler' },
|
||||
{ key: 'offer-authorization-proof', label: 'Teklif yetkisi kanıtı', term: 'Teklif vermeye yetkili olduğuna dair kanıtlayıcı belge' },
|
||||
{ key: 'uts-evidence', label: 'ÜTS beyanı ve kanıtlayıcı belgeler', term: 'ÜRÜN TAKİP SİSTEMİ (ÜTS) BEYANI VE KANITLAYICI BELGELER' },
|
||||
],
|
||||
}),
|
||||
];
|
||||
+127
-40
@@ -1,67 +1,154 @@
|
||||
// Read-only production acceptance probe for the five health/laboratory tenders.
|
||||
// No authentication, writes, payments, messages, or tender submissions.
|
||||
import { writeFile } from 'node:fs/promises';
|
||||
import { healthAcceptanceCases } from './fixtures/health-acceptance.mjs';
|
||||
// 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;
|
||||
|
||||
async function getJson(path) {
|
||||
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);
|
||||
if (!response.ok || !data || data.success !== true) throw new Error(`${path}: HTTP ${response.status}`);
|
||||
return data;
|
||||
} finally { clearTimeout(timer); }
|
||||
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 list = await getJson('/api/tenders?limit=100');
|
||||
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 healthAcceptanceCases) {
|
||||
const ikn = `2026/${fixture.ikn}`;
|
||||
const listed = byIkn.get(ikn);
|
||||
if (!listed) throw new Error(`${ikn}: live listede bulunamadı`);
|
||||
const detail = await getJson(`/api/tenders/${encodeURIComponent(listed.id)}/detail`);
|
||||
const document = await getJson(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`);
|
||||
const officialDate = document.sourceDateTime || `${document.tarih || detail.tarih || ''} ${document.saat || detail.saat || ''}`.trim();
|
||||
if (!officialDate || !document.ikn || document.ikn !== ikn) throw new Error(`${ikn}: resmi tarih/İKN doğrulanamadı`);
|
||||
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,
|
||||
ikn: fixture.ikn,
|
||||
scenario: fixture.scenario,
|
||||
id: listed.id,
|
||||
title: listed.baslik,
|
||||
institution: listed.kurum,
|
||||
city: listed.sehir,
|
||||
sector: listed.sektor,
|
||||
procedure: listed.usul,
|
||||
announcementDate: listed.ilanTarihi,
|
||||
tenderDate: document.tarih || detail.tarih || '',
|
||||
tenderTime: document.saat || detail.saat || '',
|
||||
sourceDateTime: officialDate,
|
||||
officialDateSource: 'documents/ilan sourceDateTime',
|
||||
announcementDateConflict: ikn === '2026/1573235' && listed.tarih !== document.tarih
|
||||
? `ilan listesi ${listed.tarih}; resmi detay ${document.tarih}` : null,
|
||||
detailStatus: detail.success === true,
|
||||
documentProbeStatus: 200,
|
||||
documentCount: Number(document.dokumanSayisi) || 0,
|
||||
announcementCount: Number(document.ilanSayisi) || 0,
|
||||
okasCount: Number(document.okasSayisi) || 0,
|
||||
documentEndpointSuccess: document.success === true,
|
||||
sourceUrl: listed.kaynakUrl,
|
||||
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: 'Live metadata/detail/document endpoint probes only; no authentication, writes, payments, messages, or submissions.',
|
||||
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, tenders: tenders.map(({ ikn, tenderDate, tenderTime, documentCount }) => ({ ikn, tenderDate, tenderTime, documentCount })) }));
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { sourceFailure } from '../server/source-error.mjs';
|
||||
|
||||
test('EKAP human verification is a manual non-retryable gate', () => {
|
||||
const result = sourceFailure(new Error('EKAP 428: {"code":"HUMAN_VERIFICATION_REQUIRED"}'));
|
||||
assert.equal(result.status, 428);
|
||||
assert.equal(result.body.code, 'HUMAN_VERIFICATION_REQUIRED');
|
||||
assert.equal(result.body.retryable, false);
|
||||
assert.equal(result.body.manualAction, true);
|
||||
assert.equal(result.body.manualUrl, 'https://ekap.kik.gov.tr/EKAP/');
|
||||
assert.match(result.body.error, /kendi tarayıcınızda açıp doğrulamayı tamamlayın/);
|
||||
});
|
||||
|
||||
test('ordinary upstream failures stay retryable', () => {
|
||||
const result = sourceFailure(new Error('upstream timeout'));
|
||||
assert.equal(result.status, 502);
|
||||
assert.deepEqual(result.body, {
|
||||
success: false,
|
||||
error: 'upstream timeout',
|
||||
code: 'SOURCE_UNAVAILABLE',
|
||||
retryable: true,
|
||||
});
|
||||
});
|
||||
@@ -12,6 +12,8 @@ test('detail loading rejects empty source, supports retry and stops on timeout/c
|
||||
await assert.rejects(()=>fetchTenderDetail('ilan-1'),/HTTP 503/);
|
||||
globalThis.fetch=async()=>Response.json({success:false,code:'SOURCE_UNAVAILABLE',retryable:true,error:'kaynak yok'},{status:502});
|
||||
await assert.rejects(()=>fetchTenderDetail('ilan-1'),/geçici olarak erişilemiyor.*yeniden deneyin/);
|
||||
globalThis.fetch=async()=>Response.json({success:false,code:'HUMAN_VERIFICATION_REQUIRED',retryable:false,manualAction:true,error:'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açın.'},{status:428});
|
||||
await assert.rejects(()=>fetchTenderDetail('ekap-2026/123'),/güvenlik doğrulaması.*kendi tarayıcınızda açın/);
|
||||
globalThis.fetch=async(url,{signal})=>new Promise((resolve,reject)=>{
|
||||
if(signal.aborted) reject(new DOMException('Cancelled','AbortError'));
|
||||
else signal.addEventListener('abort',()=>reject(new DOMException('Cancelled','AbortError')),{once:true});
|
||||
|
||||
Reference in New Issue
Block a user