diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index b094e28..e3ec22b 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -1,5 +1,15 @@ # Düzeltme planı — uygulama durumu +## 15 Eylül 2026 üretim ve D10 güncellemesi + +- Offline işlem kuyruğu ve veri kaybını önleyen tekrar oynatma düzeltmesi GitHub `main` dalındaki `242e7d8` sürümüyle Coolify üzerinden üretime alındı. Canlı sürüm, hazır olma uçları, güvenlik başlıkları ve giriş asset hash'i eşleşti; offline kuyruk kodu üretim paketinde bulundu. +- Tarihi açık beş laboratuvar ihalesi seçildi: `2026/1634449` (Rize), `2026/1676347` (Sivas), `2026/1667534` (Konya), `2026/1620320` (Zonguldak) ve `2026/1636687` (Ankara). +- Beşinin de canlı liste kaydı, ilan metni, tarih/saat, kapsam, teslimat, teklif geçerlilik süresi, kısmi/tam teklif hükmü ve ilan içindeki gerçek belge şartları doğrulandı. Kanıt: [`qa-live-five-tenders.json`](evidence/qa-live-five-tenders.json). +- EKAP resmi doküman paketi beş kayıtta da HTTP 428 insan doğrulaması istedi. Bu nedenle ürün eşleştirme, firma belge kanıtı, fiyat cetveli ve son çıktılar D10 pozitif kabulü olarak işaretlenmedi: ilan aşaması `5/5`, resmi paket `0/5`, tam pozitif zincir `0/5`. +- Uygulamanın bu 428 yanıtını tekrar denenebilir genel 502 hatası gibi göstermesi düzeltildi. Yeni yanıt `HUMAN_VERIFICATION_REQUIRED`, `retryable: false` ve elle yapılacak adımı içeriyor; belge ekranı kullanıcıyı EKAP doğrulamasına ve indirdiği dosyayı yüklemeye yönlendiriyor. + +Bu aşamada D10 tamamlanmış değildir. Kapanış için yetkili kullanıcının EKAP güvenlik doğrulamasından sonra beş resmi doküman paketini indirip uygulamaya yüklemesi ve test firmasının gerçek ürün/belge kayıtlarıyla kalan dört kapıyı geçmesi gerekir. Resmi teklif gönderilmedi. + 6 Eylül 2026. Temel sürüm: 909ebae. Çalışma dalı: codex/ihaleist-duzeltme-plani. Planın tamamı henüz bitmedi. Canlı yayın yapılmadı. diff --git a/server/index.js b/server/index.js index 1d0d4eb..b95653d 100644 --- a/server/index.js +++ b/server/index.js @@ -66,6 +66,7 @@ import { bildirimleriIsle } from './notifier.mjs'; import { scanSite } from './site-scan.mjs'; import { createDetailCache } from './detail-cache.mjs'; import { releaseInfo } from './release-info.mjs'; +import { sourceFailure } from './source-error.mjs'; // ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor; // zinciri tamamlamak için PEM'i yerelden ekliyoruz. @@ -663,13 +664,6 @@ function json(res, code, body) { res.end(JSON.stringify(body)); } -const sourceUnavailable = error => ({ - success: false, - error: error?.message || 'Resmi belge kaynağına ulaşılamadı.', - code: 'SOURCE_UNAVAILABLE', - retryable: true, -}); - function readRaw(req, limit = 1024 * 1024) { return new Promise((resolve, reject) => { let buf = ''; @@ -842,7 +836,8 @@ const server = http.createServer(async (req, res) => { const out = await documentUrl(ikn); return json(res, 200, { success: true, ...out }); } catch (e) { - return json(res, 502, sourceUnavailable(e)); + const failure = sourceFailure(e); + return json(res, failure.status, failure.body); } } // İlan metni + kalemler — analiz için (tarayıcı gerektirmez) @@ -854,7 +849,8 @@ const server = http.createServer(async (req, res) => { const parsed = mergeAnnouncementFields(out.metin, out); return json(res, 200, { success: true, ...out, ...parsed, tarih: out.tarih || parsed.tarih || '', saat: out.saat || parsed.saat || '' }); } catch (e) { - return json(res, 502, sourceUnavailable(e)); + const failure = sourceFailure(e); + return json(res, failure.status, failure.body); } } // Doküman metni — sunucu tarafı tarayıcı gerektirir @@ -867,7 +863,8 @@ const server = http.createServer(async (req, res) => { const out = await documentText(ikn); return json(res, 200, { success: true, ...out }); } catch (e) { - return json(res, 502, sourceUnavailable(e)); + const failure = sourceFailure(e); + return json(res, failure.status, failure.body); } } if (url.pathname === '/api/documents/config') { diff --git a/server/source-error.mjs b/server/source-error.mjs new file mode 100644 index 0000000..ea7dd19 --- /dev/null +++ b/server/source-error.mjs @@ -0,0 +1,31 @@ +const MANUAL_EKAP_URL = 'https://ekap.kik.gov.tr/EKAP/'; + +export function sourceFailure(error) { + const rawMessage = error?.message || 'Resmi belge kaynağına ulaşılamadı.'; + const humanVerification = error?.code === 'HUMAN_VERIFICATION_REQUIRED' + || /HUMAN_VERIFICATION_REQUIRED|EKAP\s+428/i.test(rawMessage); + + if (humanVerification) { + return { + status: 428, + body: { + success: false, + error: 'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açıp doğrulamayı tamamlayın; ardından indirdiğiniz dokümanı bu ihaleye yükleyin.', + code: 'HUMAN_VERIFICATION_REQUIRED', + retryable: false, + manualAction: true, + manualUrl: MANUAL_EKAP_URL, + }, + }; + } + + return { + status: 502, + body: { + success: false, + error: rawMessage, + code: 'SOURCE_UNAVAILABLE', + retryable: true, + }, + }; +} diff --git a/src/components/TenderDocuments.jsx b/src/components/TenderDocuments.jsx index a2a180b..77d8918 100644 --- a/src/components/TenderDocuments.jsx +++ b/src/components/TenderDocuments.jsx @@ -177,6 +177,12 @@ const TenderDocumentSession = ({ tender }) => { try { const r = await fetch(`/api/documents/url?ikn=${encodeURIComponent(ikn)}`); const d = await r.json(); + if (!d.success && d.code === 'HUMAN_VERIFICATION_REQUIRED' && d.manualAction && d.manualUrl) { + setEkapUrl(d.manualUrl); + setBilgi({ manualAction: true, ekapSekme: sekme ? 'acildi' : 'engellendi' }); + if (sekme) sekme.location.href = d.manualUrl; + return; + } if (!d.success) throw new Error(d.error); setEkapUrl(d.url); setBilgi({ dokumanSayisi: d.dokumanSayisi, ekapSekme: sekme ? 'acildi' : 'engellendi' }); @@ -326,16 +332,20 @@ const TenderDocumentSession = ({ tender }) => { {ekapUrl && ( -
+

- {bilgi?.ekapSekme === 'engellendi' + {bilgi?.manualAction + ? <>EKAP güvenlik doğrulaması istedi. Kendi oturumunuzda EKAP’ı açıp İKN ile arayın: + : bilgi?.ekapSekme === 'engellendi' ? <>Tarayıcınız yeni sekmeyi engelledi. : <>EKAP doküman sayfası yeni sekmede açıldı. Açılmadıysa: } EKAP doküman sayfasını aç - {' '}· Bağlantı bir süre sonra geçerliliğini yitirebilir; açılmazsa düğmeye yeniden basın. + {bilgi?.manualAction + ? <> · Doğrulamadan sonra dosyaları indirin ve aşağıdaki yükleme alanına ekleyin. + : <> · Bağlantı bir süre sonra geçerliliğini yitirebilir; açılmazsa düğmeye yeniden basın.}

)} diff --git a/src/services/tenderApi.js b/src/services/tenderApi.js index f663eed..4e764c9 100644 --- a/src/services/tenderApi.js +++ b/src/services/tenderApi.js @@ -4,11 +4,20 @@ * Dev'de Vite proxy'si /api isteklerini localhost:3001'e yönlendirir. */ +function sourceError(data, temporaryMessage) { + if (data.code === 'HUMAN_VERIFICATION_REQUIRED' && data.manualAction) { + return new Error(data.error || 'EKAP güvenlik doğrulaması gerekiyor. EKAP’ı kendi tarayıcınızda açın.'); + } + if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) return new Error(temporaryMessage); + return null; +} + export async function fetchLiveTenders({ refresh = false } = {}) { const res = await fetch(`/api/tenders${refresh ? '?refresh=1' : ''}`); const data = await res.json().catch(() => ({})); if (!res.ok) { - if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('İhale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.'); + const failure = sourceError(data, 'İhale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.'); + if (failure) throw failure; throw new Error(`API hatası: HTTP ${res.status}`); } if (!data.success) throw new Error(data.error || 'API başarısız yanıt döndü'); @@ -28,7 +37,8 @@ export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} = const res = await fetch(url, {signal:controller.signal}); const data = await res.json().catch(() => ({})); if (!res.ok) { - if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('Resmi ihale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.'); + const failure = sourceError(data, 'Resmi ihale kaynağı geçici olarak erişilemiyor. Birkaç dakika sonra yeniden deneyin.'); + if (failure) throw failure; throw new Error(`Detay API hatası: HTTP ${res.status}`); } if (!data.success) throw new Error(data.error || 'Detay alınamadı'); @@ -69,7 +79,8 @@ export async function fetchOfficialTenderDetail(ikn, {signal, timeoutMs = 30000} const res = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(cleanIkn)}`, { signal: controller.signal }); const data = await res.json().catch(() => ({})); if (!res.ok) { - if (data.code === 'SOURCE_UNAVAILABLE' && data.retryable) throw new Error('Resmî ihale kaynağı geçici olarak erişilemiyor.'); + const failure = sourceError(data, 'Resmî ihale kaynağı geçici olarak erişilemiyor.'); + if (failure) throw failure; throw new Error(`Resmî detay API hatası: HTTP ${res.status}`); } if (!data.success || data.ikn !== cleanIkn) throw new Error('Resmî detay yanıtı aranan İKN ile uyuşmuyor.'); diff --git a/tests/d10-current-lab-fixture.test.mjs b/tests/d10-current-lab-fixture.test.mjs new file mode 100644 index 0000000..aee99b1 --- /dev/null +++ b/tests/d10-current-lab-fixture.test.mjs @@ -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); + } + } +}); diff --git a/tests/fixtures/d10-current-lab.mjs b/tests/fixtures/d10-current-lab.mjs new file mode 100644 index 0000000..3f13bf3 --- /dev/null +++ b/tests/fixtures/d10-current-lab.mjs @@ -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' }, + ], + }), +]; diff --git a/tests/live-five-probe.mjs b/tests/live-five-probe.mjs index 909e66b..9fc0e03 100644 --- a/tests/live-five-probe.mjs +++ b/tests/live-five-probe.mjs @@ -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; diff --git a/tests/source-error.test.mjs b/tests/source-error.test.mjs new file mode 100644 index 0000000..e56fb00 --- /dev/null +++ b/tests/source-error.test.mjs @@ -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, + }); +}); diff --git a/tests/tender-detail-fetch.test.mjs b/tests/tender-detail-fetch.test.mjs index 4695a2c..15b7859 100644 --- a/tests/tender-detail-fetch.test.mjs +++ b/tests/tender-detail-fetch.test.mjs @@ -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});