Compare commits

..
11 Commits
33 changed files with 2021 additions and 193 deletions
+7
View File
@@ -28,6 +28,11 @@ RUN npm init -y >/dev/null \
&& npx playwright install --with-deps chromium \
&& npm cache clean --force
# Commit değişince ağır Chromium katmanını yeniden kurmadan yalnız yayın
# katmanlarını yenile. Coolify bu argümanı build içine açıkça geçirir.
ARG SOURCE_COMMIT=unknown
ENV APP_RELEASE=${SOURCE_COMMIT}
# Frontend
COPY --from=build /app/dist /usr/share/nginx/html
COPY --from=build /app/nginx.conf /etc/nginx/sites-available/default
@@ -36,6 +41,8 @@ COPY --from=build /app/nginx.conf /etc/nginx/sites-available/default
COPY --from=build /app/server ./server
# Backend, ortak doğrulama ve iş kurallarını src/services altından import eder.
COPY --from=build /app/src ./src
# .js importlarının ES module olarak doğrudan yüklenmesi için package type bilgisi.
COPY --from=build /app/package.json ./package.json
# Kalıcı veri (Coolify'da /app/server/data volume olarak bağlı)
RUN mkdir -p /app/server/data
@@ -0,0 +1,43 @@
# D09 dış entegrasyon kabul planı
Son salt okunur canlı tarama: 15 Eylül 2026, `https://ihaleist.com`.
Bu plan, dış sistemlerde yan etki oluşturmayan kontrolleri tamamlananlardan ayırır. Ödeme, e-posta, Telegram mesajı ve resmi teklif gönderimi yalnız yetkili test verileri ve açık işlem yetkisiyle yapılır. EKAP güvenlik doğrulaması otomasyonla aşılmaz.
## Mevcut durum
| Entegrasyon | Canlı durum | Doğrulanan kanıt | Kapatma adımı |
| --- | --- | --- | --- |
| ilan.gov.tr / BİK | Geçti | HTTP 200, kaynak `ilan.gov.tr`, 3.258 kayıt | D10/D04 veri kalitesi izlemesi dışında D09 işi yok |
| TCMB | Geçti | HTTP 200, 15.09.2026 bülteni ve kaynak URL’si | D09 için kapalı |
| Google ile giriş | Yapılandırılmış | Public config `configured: true` | Test hesabıyla giriş, çıkış ve oturum yenileme |
| EKAP resmi doküman | İnsan doğrulaması | OKAS ve D10 belge isteği HTTP 428, `HUMAN_VERIFICATION_REQUIRED` | Yetkili kullanıcı CAPTCHA’yı tamamlayıp beş resmi paketi indirir; dosyalar uygulamaya yüklenir |
| KİK kurul kararları | İnsan doğrulaması | HTTP 428, doğru hata kodu ve kullanıcı yönlendirmesi | EKAP doğrulamasından sonra arama ve karar metni tekrar denenir |
| TED | Geçti | HTTP 200, zaman aşımı yok | Gerçek sonuçlu sorgu ve çeviri kontrollü AI test bütçesiyle doğrulanır |
| AI yardımcıları | Yapılandırılmış | 7 sağlayıcı hazır | Yetkili test hesabı ve incelenmiş test belgesiyle analiz, soru, taslak ve denetim zinciri |
| Stripe | Yapılandırılmış | Checkout ve webhook hazır, 3 plan | Stripe test modunda başarılı/başarısız ödeme, webhook tekrar teslimi ve iptal |
| E-posta | Kimlik doğrulamayla korumalı | Yetkisiz config isteği HTTP 401 | Yetkili hesapla config okuma; izinli test alıcısına tek gönderim |
| Telegram | Engelli | Public config `configured: false` | Bot/chat yapılandırması, bağlama kodu ve izinli test sohbetine tek mesaj |
| PWA / offline worker | Geçti | Manifest ve service worker HTTP 200 | Gerçek cihazda kurulum, offline düzenleme, yeniden bağlanma ve kuyruk boşalması |
| API / RSS / MCP | Kimlik doğrulamayla korumalı | API key ve RSS 401; MCP artık proxy üzerinden 401; public sonuç RSS 200 | Yetkili hesapla anahtar oluşturma, kullanma, iptal ve iptal sonrası 401 |
| Zeyilname/sonuç zamanlayıcısı | Gözlemlenebilir | Durum uç noktası 200; iki worker etkin | Kontrollü test kaydıyla olay üretme, tek teslim ve tekrar engeli |
Canlı özet: 13 entegrasyonun 4’ü geçti, 3’ü yapılandırılmış, 2’si kimlik doğrulamayla korumalı, 1’i gözlemlenebilir, 2’si insan doğrulaması bekliyor ve 1’i yapılandırılmadığı için engelli. Korunan 14 uç noktanın 14’ü yetkisiz isteği reddetti.
## Uygulama sırası
1. **Hesap ve erişim kabulü:** Google oturumu ile API anahtarı oluşturma/kullanma/iptal zincirini aynı test hesabında doğrula.
2. **EKAP/KİK ve D10:** Yetkili kullanıcı doğrulamasından sonra beş resmi paketi yükle; her ihale için belge gereksinimi, ürün uygunluğu, firma dosyası, fiyat ve çıktı kapılarını kaydet.
3. **AI/TED:** Yalnız incelenmiş test belgesiyle gerçek sonuçlu TED araması, çeviri, analiz, belgeye soru, taslak ve denetim çıktısını kaynak atıflarıyla karşılaştır.
4. **Bildirimler:** E-posta yapılandırmasını oku; izinli test adresi ve Telegram sohbetinde tek gönderimi, hata kaydı ve tekrar engeliyle doğrula.
5. **Stripe:** Test modunda başarı, ret, iptal ve aynı webhook’un tekrar tesliminde tek abonelik değişikliği oluştuğunu doğrula.
6. **PWA ve zamanlayıcı:** Gerçek cihazı offline yap, bir düzenlemeyi kuyruğa al, yeniden bağlanınca tek kez uygulandığını doğrula; ardından kontrollü zeyilname/sonuç olayını çalıştır.
Her adımda kabul kanıtı; zaman, test hesabı kimliği, kullanılan kayıt/İKN, HTTP sonucu, ekrandaki kullanıcı mesajı ve oluşan veri değişikliğini içermelidir. Başarısız veya yapılandırılmamış entegrasyon tamamlandı olarak işaretlenmez.
## Bu sürümde düzeltilen iki hata
- Nginx, `/mcp` POST isteğini statik dosya gibi ele alıp 405 döndürüyordu. İstek artık Node API’ye proxy ediliyor ve yetkisiz çağrı doğru biçimde 401 dönüyor.
- KİK, OKAS ve ürün aramalarındaki EKAP HTTP 428 yanıtı genel 502’ye çevriliyordu. Yanıt artık `HUMAN_VERIFICATION_REQUIRED`, `retryable: false` ve kullanıcıya uygulanabilir yönlendirme içeriyor.
Makine tarafından üretilen kanıt: [`qa-live-d09-integrations.json`](evidence/qa-live-d09-integrations.json).
+11
View File
@@ -9,6 +9,7 @@ Bu çalışma notu üretim dağıtımında kullanılacak kontrol sırasını tan
- `CORS_ORIGINS`: frontend originlerinin virgülle ayrılmış tam HTTPS adresleri.
- Sağlayıcı kullanılacaksa ilgili `GOOGLE_CLIENT_ID`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, `RESEND_API_KEY` ve `TELEGRAM_BOT_TOKEN` değerleri.
- `IHALEIST_USER_DATA_DIR` ve `IHALEIST_SHARED_DATA_DIR`: kalıcı volume altında, uygulama dışından erişilemeyen dizinler.
- Coolify Advanced ayarında **Include Source Commit in Build** açık olmalıdır. Docker imajı `SOURCE_COMMIT` değerini `/api/version` yanıtındaki `release` alanına taşır; bu değer gizli değildir.
## Compose ile staging smoke testi
@@ -34,6 +35,16 @@ Coolify uygulaması `ihaleist:main` için sağlık kontrolü `GET http://localho
3. Yeni imaj staging’de başlatılır; `/api/health`, giriş, dosya yükleme/indirme ve beş sabit regresyon ihalesi doğrulanır.
4. CORS allowlistesi, auth secret ve sağlayıcı test anahtarları staging yanıtlarında kontrol edilir.
Hedef commit ve yerel frontend asset hash’i verilerek salt okunur canlı kabul çalıştırılır:
```sh
IHALEIST_EXPECTED_RELEASE="$(git rev-parse HEAD)" \
IHALEIST_EXPECTED_ASSET_SHA256="$(npm run -s qa:asset-hash)" \
npm run qa:live -- https://ihaleist.com
```
`qa:asset-hash`, `dist/index.html` içindeki giriş scriptini okuyarak yalnız gerçekten yüklenen ana JavaScript asset’inin hash’ini üretir. Probe `/api/version` release değerini, ana sayfadaki gerçek asset’i ve container içinden bildirilen asset hash’ini birlikte karşılaştırır.
## Kademeli yayın
Önce tek test firmasıyla trafik açılır. Kayıt, belge, analiz ve bildirim metrikleri izlenir. Hata oranı veya veri tutarsızlığı artarsa yeni trafik durdurulur; dış sağlayıcılara gerçek ödeme, e-posta veya resmi teklif gönderimi yapılmaz.
@@ -56,6 +56,7 @@
"printButtonHidden": true,
"overviewStatus": 200,
"overviewFrames": 3,
"documentOwnerInPreparationOverview": true,
"mobileStatus": 200,
"mobileOverflow": {
"viewport": 390,
@@ -0,0 +1,224 @@
{
"checkedAt": "2026-09-15T14:58:12.437Z",
"base": "https://ihaleist.com",
"readOnly": true,
"scope": "Yapılandırma, public kaynak okuması ve kimlik doğrulama kapıları denetlendi; giriş, veri değişikliği, AI üretimi, ödeme, mesaj veya teklif gönderimi yapılmadı.",
"integrations": [
{
"id": "BIK",
"label": "ilan.gov.tr ihale havuzu",
"status": "passed",
"evidence": {
"status": 200,
"source": "ilan.gov.tr",
"loaded": 3258
},
"remaining": "Güncel liste okuması doğrulandı; veri kalitesi D10/D04 kapsamında izlenir."
},
{
"id": "TCMB",
"label": "TCMB döviz satış kurları",
"status": "passed",
"evidence": {
"status": 200,
"bulletinDate": "15.09.2026",
"source": "https://www.tcmb.gov.tr/kurlar/today.xml",
"cached": false
},
"remaining": "Hata ve eski bülten UX testleri yerel otomasyonda kapsanıyor."
},
{
"id": "GOOGLE",
"label": "Google ile giriş",
"status": "configured",
"evidence": {
"status": 200,
"configured": true
},
"remaining": "Gerçek test hesabıyla giriş, çıkış ve oturum yenileme kabulü gerekli."
},
{
"id": "EKAP",
"label": "EKAP arama ve resmi doküman",
"status": "manual-gate",
"evidence": {
"browserAvailable": true,
"okasStatus": 428,
"okasCode": "HUMAN_VERIFICATION_REQUIRED",
"d10DocumentCode": "HUMAN_VERIFICATION_REQUIRED"
},
"remaining": "Yetkili kullanıcı güvenlik doğrulamasından sonra beş resmi paketi indirip yüklemeli."
},
{
"id": "KIK",
"label": "KİK kurul kararları",
"status": "manual-gate",
"evidence": {
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"resultCount": null,
"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."
},
"remaining": "EKAP güvenlik doğrulamasından sonra arama ve karar metni tekrar denenmeli."
},
{
"id": "TED",
"label": "TED Avrupa ihaleleri",
"status": "passed",
"evidence": {
"status": 200,
"totalCount": 0,
"timedOut": false,
"error": null
},
"remaining": "Gerçek sonuçlu sorgu ve çeviri, kontrollü AI test bütçesiyle yapılmalı."
},
{
"id": "AI",
"label": "AI analiz ve teklif yardımcıları",
"status": "configured",
"evidence": {
"status": 200,
"configured": true,
"providerCount": 7
},
"remaining": "Yetkili test hesabı ve incelemesi tamamlanmış test belgesiyle analiz, soru, taslak ve denetim zinciri gerekli."
},
{
"id": "STRIPE",
"label": "Stripe plan, ödeme ve webhook",
"status": "configured",
"evidence": {
"status": 200,
"checkoutConfigured": true,
"webhookConfigured": true,
"planCount": 3
},
"remaining": "Yalnız Stripe test ortamında başarılı/başarısız ödeme, webhook tekrarı ve iptal senaryoları gerekli."
},
{
"id": "EMAIL",
"label": "E-posta bildirimleri",
"status": "guarded",
"evidence": {
"unauthenticatedStatus": 401
},
"remaining": "Yetkili test hesabında yapılandırma okunmalı; izinli test alıcısına tek gönderim ayrıca onaylanmalı."
},
{
"id": "TELEGRAM",
"label": "Telegram bağlantısı ve bildirim",
"status": "blocked",
"evidence": {
"status": 200,
"configured": false
},
"remaining": "Test sohbetinde bağlama kodu ve tek test mesajı için açık gönderim yetkisi gerekli."
},
{
"id": "PWA",
"label": "PWA manifest ve offline worker",
"status": "passed",
"evidence": {
"manifestStatus": 200,
"serviceWorkerStatus": 200
},
"remaining": "Gerçek cihazda kurulum, offline düzenleme ve yeniden bağlanma kabulü ayrıca izlenir."
},
{
"id": "API",
"label": "API anahtarı, RSS ve MCP",
"status": "guarded",
"evidence": {
"apiKeyGuard": 401,
"rssGuard": 401,
"mcpGuard": 401,
"publicResultsRssStatus": 200
},
"remaining": "Yetkili test hesabında anahtar oluşturma, RSS/MCP kullanma ve iptal zinciri gerekli."
},
{
"id": "SCHEDULER",
"label": "Zeyilname/sonuç zamanlayıcısı",
"status": "observable",
"evidence": {
"status": 200,
"browserAvailable": true,
"resultScanActive": true,
"resultScanRunning": false,
"directProcurementActive": true,
"directProcurementRunning": false
},
"remaining": "Yetkili test kaydıyla zeyilname/sonuç olayı ve tekrar engeli doğrulanmalı."
}
],
"guards": {
"authSession": {
"status": 401,
"passed": true
},
"documents": {
"status": 401,
"passed": true
},
"documentText": {
"status": 401,
"passed": true
},
"apiKeys": {
"status": 401,
"passed": true
},
"emailConfig": {
"status": 401,
"passed": true
},
"plan": {
"status": 401,
"passed": true
},
"subscriptions": {
"status": 401,
"passed": true
},
"sourceHistory": {
"status": 401,
"passed": true
},
"companyScan": {
"status": 401,
"passed": true
},
"aiAnalyze": {
"status": 401,
"passed": true
},
"checkout": {
"status": 401,
"passed": true
},
"emailSend": {
"status": 401,
"passed": true
},
"telegramMutation": {
"status": 401,
"passed": true
},
"notificationRun": {
"status": 403,
"passed": true
}
},
"summary": {
"total": 13,
"passed": 4,
"configured": 3,
"guarded": 2,
"observable": 1,
"manualGate": 2,
"blocked": 1,
"protectedGuardsPassed": 14,
"protectedGuardsTotal": 14
}
}
+629 -109
View File
@@ -1,124 +1,644 @@
{
"checkedAt": "2026-09-06T17:39:49.038Z",
"checkedAt": "2026-09-15T14:45:21.910Z",
"base": "https://ihaleist.com",
"readOnly": true,
"listCount": 8224,
"tenders": [
"listProbe": {
"status": 200,
"success": true
},
"listCount": 10898,
"selectedCount": 5,
"announcementPassCount": 5,
"officialDocumentPackagePassCount": 0,
"positiveCount": 0,
"pass": false,
"blockers": [
{
"ikn": "2026/1582991",
"scenario": "mikroskop",
"id": "ilan-2204986",
"title": "Mikroskop satın alınacaktır",
"institution": "GİRESUN EĞİTİM VE ARAŞTIRMA HASTANESİ BAŞHEKİMLİĞİ",
"city": "Giresun",
"sector": "Laboratuvar & Ölçüm",
"procedure": "4734 Sayılı KİK, Açık İhale Usulü",
"announcementDate": "2026-08-27",
"tenderDate": "10.09.2026",
"tenderTime": "10:00",
"sourceDateTime": "10.09.2026 10:00",
"officialDateSource": "documents/ilan sourceDateTime",
"announcementDateConflict": null,
"detailStatus": true,
"documentProbeStatus": 200,
"documentCount": 0,
"announcementCount": 1,
"okasCount": 2,
"documentEndpointSuccess": true,
"sourceUrl": "https://www.ilan.gov.tr/ilan/2204986/ihale-duyurulari-mal-alim-ihaleleri-mikroskop-satin-alinacaktir"
"ikn": "2026/1634449",
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "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."
},
{
"ikn": "2026/1534665",
"scenario": "sarf",
"id": "ilan-2205024",
"title": "26 kalem laboratuvar sarf malzeme alımı",
"institution": "KAYSERİ DEVLET HASTANESİ BAŞHEKİMLİĞİ",
"city": "Kayseri",
"sector": "Laboratuvar & Ölçüm",
"procedure": "4734 Sayılı KİK, Açık İhale Usulü",
"announcementDate": "2026-08-31",
"tenderDate": "22.09.2026",
"tenderTime": "11:00",
"sourceDateTime": "22.09.2026 11:00",
"officialDateSource": "documents/ilan sourceDateTime",
"announcementDateConflict": null,
"detailStatus": true,
"documentProbeStatus": 200,
"documentCount": 0,
"announcementCount": 1,
"okasCount": 1,
"documentEndpointSuccess": true,
"sourceUrl": "https://www.ilan.gov.tr/ilan/2205024/ihale-duyurulari-mal-alim-ihaleleri-26-kalem-laboratuvar-sarf-malzeme-alimi"
"ikn": "2026/1676347",
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "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."
},
{
"ikn": "2026/1499061",
"scenario": "cihaz-servis",
"id": "ilan-2204525",
"title": "Laboratuvar malzemesi alınacaktır",
"institution": "AKDENİZ ÜNİVERSİTESİ REKTÖRLÜĞÜ ÜNİVERSİTE HASTANESİ BAŞ MÜDÜRLÜĞÜ",
"city": "Antalya",
"sector": "Laboratuvar & Ölçüm",
"procedure": "4734 Sayılı KİK, Açık İhale Usulü",
"announcementDate": "2026-08-27",
"tenderDate": "22.09.2026",
"tenderTime": "10:30",
"sourceDateTime": "22.09.2026 10:30",
"officialDateSource": "documents/ilan sourceDateTime",
"announcementDateConflict": null,
"detailStatus": true,
"documentProbeStatus": 200,
"documentCount": 0,
"announcementCount": 1,
"okasCount": 1,
"documentEndpointSuccess": true,
"sourceUrl": "https://www.ilan.gov.tr/ilan/2204525/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-malzemesi-alinacaktir"
"ikn": "2026/1667534",
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "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."
},
{
"ikn": "2026/1573235",
"scenario": "kismi-teklif",
"id": "ilan-2203314",
"title": "Laboratuvar sarf malzemeleri satın alınacak",
"institution": "AKSARAY ÜNİVERSİTESİ EĞİTİM VE ARAŞTIRMA HASTANESİ",
"city": "Aksaray",
"sector": "Laboratuvar & Ölçüm",
"procedure": "4734 Sayılı KİK, Açık İhale Usulü",
"announcementDate": "2026-08-24",
"tenderDate": "16.09.2026",
"tenderTime": "10:00",
"sourceDateTime": "16.09.2026 10:00",
"officialDateSource": "documents/ilan sourceDateTime",
"announcementDateConflict": "ilan listesi 09.09.2026; resmi detay 16.09.2026",
"detailStatus": true,
"documentProbeStatus": 200,
"documentCount": 1,
"announcementCount": 1,
"okasCount": 1,
"documentEndpointSuccess": true,
"sourceUrl": "https://www.ilan.gov.tr/ilan/2203314/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-sarf-malzemeleri-satin-alinacak"
"ikn": "2026/1620320",
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "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."
},
{
"ikn": "2026/1523851",
"scenario": "100-kalem",
"id": "ilan-2200563",
"title": "Laboratuvar malzemesi satın alınacaktır",
"institution": "İZMİR TEPECİK EĞİTİM VE ARAŞTIRMA HASTANESİ",
"city": "İzmir",
"sector": "Laboratuvar & Ölçüm",
"procedure": "4734 Sayılı KİK, Açık İhale Usulü",
"announcementDate": "2026-08-21",
"tenderDate": "11.09.2026",
"tenderTime": "10:00",
"sourceDateTime": "11.09.2026 10:00",
"officialDateSource": "documents/ilan sourceDateTime",
"announcementDateConflict": null,
"detailStatus": true,
"documentProbeStatus": 200,
"documentCount": 1,
"announcementCount": 1,
"okasCount": 1,
"documentEndpointSuccess": true,
"sourceUrl": "https://www.ilan.gov.tr/ilan/2200563/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-malzemesi-satin-alinacaktir"
"ikn": "2026/1636687",
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "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."
}
],
"scope": "Live metadata/detail/document endpoint probes only; no authentication, writes, payments, messages, or submissions."
"tenders": [
{
"ikn": "2026/1634449",
"scenario": "20-kalem-laboratuvar-cihazi",
"id": "ilan-2219605",
"title": "Laboratuvar cihazı satın alınacaktır",
"institution": "R.T.E. ÜNİVERSİTESİ İDARİ VE MALİ İŞLER DAİRE BAŞKANLIĞI",
"city": "Rize",
"tenderDate": "05.10.2026",
"tenderTime": "10:30",
"sourceUrl": "https://www.ilan.gov.tr/ilan/2219605/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-cihazi-satin-alinacaktir",
"publicAnnouncement": {
"endpointStatus": 200,
"textChars": 5592,
"normalizedTextSha256": "17456819e593dca234080014db40aa47532d01b8b095a5e3f0976838d93cd1a1",
"checks": {
"listed": true,
"detailEndpoint": true,
"ikn": true,
"title": true,
"date": true,
"time": true,
"nature": true,
"delivery": true,
"validity": true,
"partialOffer": true,
"requirements": true
},
"requirementChecks": [
{
"key": "offer-letter",
"label": "Teklif mektubu",
"sourceTerm": "Teklif mektubu",
"present": true
},
{
"key": "authorization",
"label": "Teklif vermeye yetki bilgileri",
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
"present": true
},
{
"key": "temporary-guarantee",
"label": "Geçici teminat",
"sourceTerm": "Geçici teminat",
"present": true
},
{
"key": "manufacturer-or-authorized-seller",
"label": "İmalatçı/yetkili satıcı/yetkili temsilci belgesi",
"sourceTerm": "Yetkili Satıcı Belgesi",
"present": true
},
{
"key": "service-qualification",
"label": "TSE Hizmet Yeterlilik Belgesi",
"sourceTerm": "TSE Hizmet Yeterlilik Belgesi",
"present": true
},
{
"key": "catalog",
"label": "Katalog",
"sourceTerm": "Katalog",
"present": true
},
{
"key": "brand-model",
"label": "Marka-model beyanı",
"sourceTerm": "MARKA - MODEL BEYANI",
"present": true
},
{
"key": "specification-response",
"label": "Teknik şartnameye cevaplar ve açıklamalar",
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
"present": true
}
]
},
"officialDocumentProbe": {
"endpointStatus": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"documentCount": 0,
"announcementCount": 0,
"okasCount": 0,
"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."
},
"stages": {
"selectionAndAnnouncement": {
"passed": true,
"status": "passed"
},
"officialDocumentPackage": {
"passed": false,
"status": "blocked",
"blocker": {
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "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."
}
},
"productMatch": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"companyDocumentEvidence": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"priceAndOutputs": {
"passed": false,
"status": "blocked-by-official-document-package"
}
},
"positive": false
},
{
"ikn": "2026/1676347",
"scenario": "cihaz-malzeme-yazilim",
"id": "ilan-2218485",
"title": "Laboratuvar cihaz, malzeme ve yazılım mal alım ihalesi",
"institution": "SİVAS BİLİM VE TEKNİK ÜNİVERSİTESİ İDARİ VE MALİ İŞL. DAİRE BAŞK.",
"city": "Sivas",
"tenderDate": "01.10.2026",
"tenderTime": "10:30",
"sourceUrl": "https://www.ilan.gov.tr/ilan/2218485/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-cihaz-malzeme-ve-yazilim-mal-alim-ihalesi",
"publicAnnouncement": {
"endpointStatus": 200,
"textChars": 4455,
"normalizedTextSha256": "64902b106e05f9feff268530435498e65a067efc0f51cb665edd9f878d372cd7",
"checks": {
"listed": true,
"detailEndpoint": true,
"ikn": true,
"title": true,
"date": true,
"time": true,
"nature": true,
"delivery": true,
"validity": true,
"partialOffer": true,
"requirements": true
},
"requirementChecks": [
{
"key": "offer-letter",
"label": "Teklif mektubu",
"sourceTerm": "Teklif mektubu",
"present": true
},
{
"key": "authorization",
"label": "Teklif vermeye yetki bilgileri",
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
"present": true
},
{
"key": "temporary-guarantee",
"label": "Geçici teminat",
"sourceTerm": "Geçici teminat",
"present": true
},
{
"key": "catalog",
"label": "Katalog",
"sourceTerm": "Katalog",
"present": true
},
{
"key": "specification-response",
"label": "Teknik şartnameye cevaplar ve açıklamalar",
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
"present": true
}
]
},
"officialDocumentProbe": {
"endpointStatus": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"documentCount": 0,
"announcementCount": 0,
"okasCount": 0,
"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."
},
"stages": {
"selectionAndAnnouncement": {
"passed": true,
"status": "passed"
},
"officialDocumentPackage": {
"passed": false,
"status": "blocked",
"blocker": {
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "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."
}
},
"productMatch": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"companyDocumentEvidence": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"priceAndOutputs": {
"passed": false,
"status": "blocked-by-official-document-package"
}
},
"positive": false
},
{
"ikn": "2026/1667534",
"scenario": "yedek-parca-ve-sarf",
"id": "ilan-2217472",
"title": "Laboratuvar cihaz yedek parça ve sarf malzeme satın alınacak",
"institution": "KONYA İL SAĞLIK MÜDÜRLÜĞÜ",
"city": "Konya",
"tenderDate": "28.09.2026",
"tenderTime": "10:30",
"sourceUrl": "https://www.ilan.gov.tr/ilan/2217472/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-cihaz-yedek-parca-ve-sarf-malzeme-satin-alinacak",
"publicAnnouncement": {
"endpointStatus": 200,
"textChars": 4897,
"normalizedTextSha256": "d076d3e94136ccfd9e1cd1eb1e98e1bcb600bce20bf7d8ae7d5d3ea0a2f13044",
"checks": {
"listed": true,
"detailEndpoint": true,
"ikn": true,
"title": true,
"date": true,
"time": true,
"nature": true,
"delivery": true,
"validity": true,
"partialOffer": true,
"requirements": true
},
"requirementChecks": [
{
"key": "offer-letter",
"label": "Teklif mektubu",
"sourceTerm": "Teklif mektubu",
"present": true
},
{
"key": "authorization",
"label": "Teklif vermeye yetki bilgileri",
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
"present": true
},
{
"key": "temporary-guarantee",
"label": "Geçici teminat",
"sourceTerm": "Geçici teminat",
"present": true
},
{
"key": "catalog",
"label": "Katalog",
"sourceTerm": "Katalog",
"present": true
},
{
"key": "specification-response",
"label": "Teknik şartnameye cevaplar ve açıklamalar",
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
"present": true
},
{
"key": "sample",
"label": "Numune",
"sourceTerm": "Numune sunulması istenmektedir",
"present": true
}
]
},
"officialDocumentProbe": {
"endpointStatus": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"documentCount": 0,
"announcementCount": 0,
"okasCount": 0,
"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."
},
"stages": {
"selectionAndAnnouncement": {
"passed": true,
"status": "passed"
},
"officialDocumentPackage": {
"passed": false,
"status": "blocked",
"blocker": {
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "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."
}
},
"productMatch": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"companyDocumentEvidence": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"priceAndOutputs": {
"passed": false,
"status": "blocked-by-official-document-package"
}
},
"positive": false
},
{
"ikn": "2026/1620320",
"scenario": "220-kalem-laboratuvar-sarfi",
"id": "ilan-2214836",
"title": "Laboratuvar sarf malzemeleri satın alınacaktır",
"institution": "ZONGULDAK İL SAĞLIK MÜDÜRLÜĞÜ",
"city": "Zonguldak",
"tenderDate": "01.10.2026",
"tenderTime": "10:00",
"sourceUrl": "https://www.ilan.gov.tr/ilan/2214836/ihale-duyurulari-mal-alim-ihaleleri-laboratuvar-sarf-malzemeleri-satin-alinacaktir",
"publicAnnouncement": {
"endpointStatus": 200,
"textChars": 4891,
"normalizedTextSha256": "3a82b6347d3237ba29c57e211f46febd48ffd3570a7a4d818af04789fd0be5d0",
"checks": {
"listed": true,
"detailEndpoint": true,
"ikn": true,
"title": true,
"date": true,
"time": true,
"nature": true,
"delivery": true,
"validity": true,
"partialOffer": true,
"requirements": true
},
"requirementChecks": [
{
"key": "offer-letter",
"label": "Teklif mektubu",
"sourceTerm": "Teklif mektubu",
"present": true
},
{
"key": "authorization",
"label": "Teklif vermeye yetki bilgileri",
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
"present": true
},
{
"key": "temporary-guarantee",
"label": "Geçici teminat",
"sourceTerm": "Geçici teminat",
"present": true
},
{
"key": "scope-exclusion",
"label": "Kapsam Dışı Belgesi",
"sourceTerm": "Kapsam Dışı Belgesi",
"present": true
},
{
"key": "sales-center",
"label": "Satış Merkezi Yetki Belgesi",
"sourceTerm": "Satış Merkezi Yetki Belgesi",
"present": true
},
{
"key": "uts",
"label": "TİTUBB veya ÜTS belgesi",
"sourceTerm": "TİTUBB veya ÜTS belgesi",
"present": true
},
{
"key": "sample",
"label": "Numune",
"sourceTerm": "Numune sunulması istenmektedir",
"present": true
}
]
},
"officialDocumentProbe": {
"endpointStatus": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"documentCount": 0,
"announcementCount": 0,
"okasCount": 0,
"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."
},
"stages": {
"selectionAndAnnouncement": {
"passed": true,
"status": "passed"
},
"officialDocumentPackage": {
"passed": false,
"status": "blocked",
"blocker": {
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "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."
}
},
"productMatch": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"companyDocumentEvidence": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"priceAndOutputs": {
"passed": false,
"status": "blocked-by-official-document-package"
}
},
"positive": false
},
{
"ikn": "2026/1636687",
"scenario": "kit-karsiligi-otoanalizor",
"id": "ilan-2211682",
"title": "Kit karşılığı otoanalizör satın alınacak",
"institution": "GAZİ ÜNİVERSİTESİ SAĞLIK ARAŞTIRMA VE UYGULAMA MERKEZİ",
"city": "Ankara",
"tenderDate": "28.09.2026",
"tenderTime": "14:00",
"sourceUrl": "https://www.ilan.gov.tr/ilan/2211682/ihale-duyurulari-mal-alim-ihaleleri-kit-karsiligi-otoanalizor-satin-alinacak",
"publicAnnouncement": {
"endpointStatus": 200,
"textChars": 5820,
"normalizedTextSha256": "8cbd40ad56a471132a881f1b922739f80c3ad3dea99bd18a556acf7dd3b9975f",
"checks": {
"listed": true,
"detailEndpoint": true,
"ikn": true,
"title": true,
"date": true,
"time": true,
"nature": true,
"delivery": true,
"validity": true,
"partialOffer": true,
"requirements": true
},
"requirementChecks": [
{
"key": "offer-letter",
"label": "Teklif mektubu",
"sourceTerm": "Teklif mektubu",
"present": true
},
{
"key": "authorization",
"label": "Teklif vermeye yetki bilgileri",
"sourceTerm": "Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler",
"present": true
},
{
"key": "temporary-guarantee",
"label": "Geçici teminat",
"sourceTerm": "Geçici teminat",
"present": true
},
{
"key": "service-training",
"label": "Teknik Servis Eğitim Sertifikası",
"sourceTerm": "Teknik Servis Eğitim Sertifikası",
"present": true
},
{
"key": "brochure",
"label": "Broşür",
"sourceTerm": "Broşür",
"present": true
},
{
"key": "user-guide",
"label": "Cihaz ve ODS kullanım kılavuzu",
"sourceTerm": "ODS yazılım sistemine ait kullanım klavuzu",
"present": true
},
{
"key": "catalog",
"label": "Katalog",
"sourceTerm": "Katalog",
"present": true
},
{
"key": "specification-response",
"label": "Teknik şartnameye cevaplar ve açıklamalar",
"sourceTerm": "Teknik Şartnameye Cevaplar ve Açıklamalar",
"present": true
},
{
"key": "sales-center",
"label": "Satış Merkezi Yetki Belgesi",
"sourceTerm": "Satış Merkezi Yetki Belgesi",
"present": true
},
{
"key": "device-id-cards",
"label": "Cihaz ve sistem tanıtım kimlik kartı",
"sourceTerm": "tanıtım kimlik kartı",
"present": true
},
{
"key": "production-and-age",
"label": "Üretimde olma ve 13 yaş altı kanıtı",
"sourceTerm": "on üç yaşın altında olduğunu belirten belgeler",
"present": true
},
{
"key": "offer-authorization-proof",
"label": "Teklif yetkisi kanıtı",
"sourceTerm": "Teklif vermeye yetkili olduğuna dair kanıtlayıcı belge",
"present": true
},
{
"key": "uts-evidence",
"label": "ÜTS beyanı ve kanıtlayıcı belgeler",
"sourceTerm": "ÜRÜN TAKİP SİSTEMİ (ÜTS) BEYANI VE KANITLAYICI BELGELER",
"present": true
}
]
},
"officialDocumentProbe": {
"endpointStatus": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"documentCount": 0,
"announcementCount": 0,
"okasCount": 0,
"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."
},
"stages": {
"selectionAndAnnouncement": {
"passed": true,
"status": "passed"
},
"officialDocumentPackage": {
"passed": false,
"status": "blocked",
"blocker": {
"stage": "officialDocumentPackage",
"status": 428,
"code": "HUMAN_VERIFICATION_REQUIRED",
"retryable": false,
"message": "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."
}
},
"productMatch": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"companyDocumentEvidence": {
"passed": false,
"status": "blocked-by-official-document-package"
},
"priceAndOutputs": {
"passed": false,
"status": "blocked-by-official-document-package"
}
},
"positive": false
}
],
"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ı."
}
+61 -1
View File
@@ -1,7 +1,9 @@
{
"base": "https://ihaleist.com",
"readOnly": true,
"checkedAt": "2026-09-06T17:30:38.609Z",
"checkedAt": "2026-09-15T14:45:06.693Z",
"expectedRelease": "e5e1b12185b98a8ddf9e05a88381a5f0e377d60e",
"expectedAssetSha256": "4bca821e3b14ef1e349cd31e312c5027fa8527d17ecc2697852a004a6826eb7e",
"checks": {
"home": {
"status": 200,
@@ -55,8 +57,66 @@
}
},
"apiSuccess": true
},
"version": {
"status": 200,
"contentType": "application/json; charset=utf-8",
"headers": {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=()",
"x-frame-options": "DENY",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
"strict-transport-security": "max-age=31536000; includeSubDomains"
},
"json": {
"ok": true,
"release": "e5e1b12185b98a8ddf9e05a88381a5f0e377d60e",
"version": "1.0.0",
"frontendAsset": {
"path": "/assets/index-vuTn9Luj.js",
"sha256": "4bca821e3b14ef1e349cd31e312c5027fa8527d17ecc2697852a004a6826eb7e",
"bytes": 569810
}
},
"apiSuccess": true
},
"documents": {
"status": 200,
"contentType": "application/json; charset=utf-8",
"headers": {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=()",
"x-frame-options": "DENY",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
"strict-transport-security": "max-age=31536000; includeSubDomains"
},
"json": {
"tarayiciVar": true
},
"apiSuccess": true
},
"frontendAsset": {
"status": 200,
"contentType": "application/javascript",
"headers": {
"x-content-type-options": "nosniff",
"referrer-policy": "no-referrer",
"permissions-policy": "camera=(), microphone=(), geolocation=()",
"x-frame-options": "DENY",
"content-security-policy": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com",
"strict-transport-security": "max-age=31536000; includeSubDomains"
},
"path": "/assets/index-vuTn9Luj.js",
"bytes": 569810,
"sha256": "4bca821e3b14ef1e349cd31e312c5027fa8527d17ecc2697852a004a6826eb7e"
}
},
"releaseValid": true,
"releaseMatches": true,
"assetMatchesRuntime": true,
"assetMatchesExpected": true,
"pass": true,
"missingHeaders": []
}
+24 -2
View File
@@ -1,8 +1,30 @@
# Düzeltme planı — uygulama durumu
6 Eylül 2026. Temel sürüm: 909ebae. Çalışma dalı: codex/ihaleist-duzeltme-plani.
## 15 Eylül 2026 üretim ve D10 güncellemesi
Planın tamamı henüz bitmedi. Canlı yayın yapılmadı.
- 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 `e5e1b12` sürümünde düzeltildi ve canlıda doğrulandı. 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.
## 15 Eylül 2026 D09 dış entegrasyon kabulü
- `6c32581` sürümü Coolify üzerinden üretime alındı. Canlı `/api/version` tam commit SHA’sını ve beklenen frontend asset SHA-256 özetini döndürdü; health, ready ve güvenlik başlıkları kabulü geçti.
- Salt okunur probda 13 entegrasyon denetlendi: 4 geçti, 3 yapılandırılmış, 2 kimlik doğrulamayla korumalı, 1 gözlemlenebilir, 2 insan doğrulaması bekliyor ve 1 engelli. Yetkisiz istek kapılarının 14/14’ü doğru şekilde reddedildi.
- BİK, TCMB, TED bağlantısı ve PWA assetleri geçti. Google, AI ve Stripe yapılandırılmış. E-posta ile API/RSS/MCP doğru kimlik doğrulama kapısında. Scheduler durumu okunuyor. EKAP/KİK güvenlik doğrulaması bekliyor. Telegram yapılandırılmamış tek engelli entegrasyondur.
- `/mcp` POST isteğinin Nginx’te 405’e düşmesi düzeltildi; canlıda artık Node kimlik doğrulama katmanından 401 dönüyor. KİK/OKAS/ürün aramasındaki HTTP 428 yanıtının genel 502’ye çevrilmesi de giderildi.
- Test, giriş yapmadı; AI üretimi, ödeme, e-posta/Telegram mesajı veya resmi teklif göndermedi. Yan etkili kapanış adımları yetkili test hesapları ve açık işlem yetkisiyle ayrı ayrı uygulanacaktır.
Ayrıntılı sıra ve kabul ölçütleri: [`d09-entegrasyon-kabul-plani.md`](d09-entegrasyon-kabul-plani.md). Canlı kanıt: [`qa-live-d09-integrations.json`](evidence/qa-live-d09-integrations.json).
## 6 Eylül 2026 başlangıç durumu
Temel sürüm: 909ebae. Çalışma dalı: codex/ihaleist-duzeltme-plani. Bu bölüm tarihsel başlangıç kaydıdır.
Planın tamamı o tarihte henüz bitmemişti ve canlı yayın yapılmamıştı.
Canlı read-only probe (6 Eylül 2026) `/api/health` için 200 döndürürken `/api/ready` için 404 döndü; canlı API yanıtında yeni güvenlik başlıkları da yok. Bu, yerel dalın henüz `ihaleist.com` ortamına dağıtılmadığını doğruluyor. Kanıt: [`qa-live-runtime.json`](evidence/qa-live-runtime.json). Probe sırasında giriş, veri değişikliği, ödeme, mesaj veya ihale başvurusu yapılmadı.
+25
View File
@@ -41,11 +41,36 @@ server {
proxy_connect_timeout 10s;
}
# MCP JSON-RPC ucu /api öneki taşımaz. Ayrı proxy olmadan Nginx POST'u
# statik dosya isteği sayıp 405 döndürür ve Node'daki API anahtarı kontrolü
# hiç çalışmaz.
location = /mcp {
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_hide_header X-Content-Type-Options;
proxy_hide_header Referrer-Policy;
proxy_hide_header Permissions-Policy;
client_max_body_size 1m;
proxy_read_timeout 60s;
proxy_send_timeout 60s;
proxy_connect_timeout 10s;
}
# Statik varlıklar
location /assets/ {
root /usr/share/nginx/html;
try_files $uri =404;
expires 30d;
# Bu blok kendi add_header direktifini kullandığı için üst seviyedeki
# başlıkları nginx otomatik miras almaz; güvenlik başlıklarını tekrarla.
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "no-referrer" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
add_header X-Frame-Options "DENY" always;
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'none'; script-src 'self' https://accounts.google.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src 'self' https://fonts.gstatic.com data:; img-src 'self' data: blob: https:; connect-src 'self' https:; worker-src 'self' blob:; frame-src 'self' https://accounts.google.com" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Cache-Control "public, no-transform";
}
+3 -1
View File
@@ -7,11 +7,13 @@
"dev": "vite",
"build": "vite build",
"qa:browser": "node tests/browser-acceptance.mjs",
"qa:asset-hash": "node scripts/asset-fingerprint.mjs",
"qa:live": "node tests/live-runtime-probe.mjs",
"preview": "vite preview",
"server": "node server/index.js",
"test": "node --test tests/*.test.mjs",
"qa:live-five": "node tests/live-five-probe.mjs"
"qa:live-five": "node tests/live-five-probe.mjs",
"qa:live-integrations": "node tests/live-integrations-probe.mjs"
},
"dependencies": {
"canvas-confetti": "^1.9.4",
+10
View File
@@ -0,0 +1,10 @@
import path from 'node:path';
import { frontendAssetInfo } from '../server/release-info.mjs';
const asset = frontendAssetInfo([path.resolve(process.cwd(), 'dist')]);
if (!asset) {
console.error('dist/index.html içinde yüklenen frontend JavaScript asset’i bulunamadı. Önce npm run build çalıştırın.');
process.exit(1);
}
process.stdout.write(`${asset.sha256}\n`);
+45 -14
View File
@@ -65,6 +65,8 @@ import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolv
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.
@@ -75,6 +77,7 @@ const CA_BUNDLE = [...tls.rootCertificates, EXTRA_CA];
const PORT = process.env.PORT || 3001;
const ILAN_BASE = 'https://www.ilan.gov.tr';
const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean);
const RUNTIME_RELEASE = releaseInfo();
function readiness() {
const directories = {
@@ -661,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 = '';
@@ -840,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)
@@ -852,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
@@ -865,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') {
@@ -943,7 +942,8 @@ const server = http.createServer(async (req, res) => {
if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
// OKAS kataloğunda ad ile arama — idarenin kendi sınıflandırma kodunu bulmak için
@@ -958,7 +958,8 @@ const server = http.createServer(async (req, res) => {
urunAramaCache.set(anahtar, { zaman: Date.now(), veri: kalemler });
return json(res, 200, { success: true, kalemler });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
// --- Anlamsal uyum bonusu: profil metni ↔ havuz (sunucuda gömme) ---
@@ -1033,6 +1034,33 @@ const server = http.createServer(async (req, res) => {
const [z, so] = await Promise.all([zeyilnameTara(), sonucTara()]);
return json(res, 200, { success: true, zeyilname: z, sonuc: so });
}
// --- Abone Takip Paneli Entegrasyonu (Signups & Subscribers) ---
if ((url.pathname === '/api/admin/signups' || url.pathname === '/api/admin/subscribers') && req.method === 'GET') {
const authHeader = req.headers['authorization'] || '';
const apiKeyHeader = req.headers['x-api-key'] || req.headers['x-webhook-secret'] || '';
const expectedKey = process.env.ABONE_TAKIP_SECRET || 'abone_takip_master_token_2026';
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7).trim() : apiKeyHeader.trim();
if (!token || (token !== expectedKey)) {
return json(res, 401, { success: false, error: 'Yetkisiz erişim' });
}
const users = store.withDb ? store.withDb((db) => db.prepare('SELECT email, ad, plan, olusturma, son_giris FROM kullanicilar').all(), []) : [];
const data = users.map(u => ({
email: u.email,
name: u.ad || null,
plan: u.plan || 'ucretsiz',
status: 'active',
is_paid: (u.plan && u.plan !== 'ucretsiz') ? 1 : 0,
payment_amount: (u.plan && u.plan !== 'ucretsiz') ? 499 : 0,
currency: 'TRY',
registered_at: u.olusturma || new Date().toISOString(),
last_login_at: u.son_giris || null
}));
return json(res, 200, { success: true, data });
}
if (url.pathname === '/api/health/kalem') {
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
}
@@ -1054,7 +1082,8 @@ const server = http.createServer(async (req, res) => {
);
return json(res, 200, { success: true, ...out });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
if (url.pathname === '/api/kik/karar') {
@@ -1064,7 +1093,8 @@ const server = http.createServer(async (req, res) => {
const metin = await kararMetni(id);
return json(res, 200, { success: true, id, metin });
} catch (e) {
return json(res, 502, { success: false, error: e.message });
const failure = sourceFailure(e);
return json(res, failure.status, failure.body);
}
}
@@ -1732,6 +1762,7 @@ const server = http.createServer(async (req, res) => {
}
if (url.pathname === '/api/health') return json(res, 200, { ok: true });
if (url.pathname === '/api/version') return json(res, 200, { ok: true, ...RUNTIME_RELEASE });
if (url.pathname === '/api/ready') {
const status = readiness();
return json(res, status.ready ? 200 : 503, { ok: status.ready, ...status });
+50
View File
@@ -0,0 +1,50 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
const DEFAULT_ASSET_ROOTS = [
'/usr/share/nginx/html',
path.resolve(process.cwd(), 'dist'),
];
function cleanRelease(value) {
const release = String(value || '').trim();
if (!release || release === 'unknown' || release.length > 128) return null;
return release;
}
export function deployedRelease(env = process.env) {
return cleanRelease(env.APP_RELEASE) || cleanRelease(env.SOURCE_COMMIT);
}
export function frontendAssetInfo(roots = DEFAULT_ASSET_ROOTS) {
for (const root of roots) {
try {
const indexPath = path.join(root, 'index.html');
const html = fs.readFileSync(indexPath, 'utf8');
const match = html.match(/<script[^>]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i);
if (!match) continue;
const publicPath = new URL(match[1], 'https://ihaleist.invalid/').pathname;
const assetPath = path.resolve(root, `.${publicPath}`);
const rootPath = path.resolve(root) + path.sep;
if (!assetPath.startsWith(rootPath)) continue;
const bytes = fs.readFileSync(assetPath);
return {
path: publicPath,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
bytes: bytes.length,
};
} catch {
// Bu kökte derlenmiş arayüz yoksa sıradaki olası köke bakılır.
}
}
return null;
}
export function releaseInfo({ env = process.env, roots = DEFAULT_ASSET_ROOTS } = {}) {
return {
release: deployedRelease(env),
version: '1.0.0',
frontendAsset: frontendAssetInfo(roots),
};
}
+31
View File
@@ -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|KİK)[^\n]{0,120}(?:HTTP\s*)?428|HTTP\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,
},
};
}
+11 -7
View File
@@ -38,7 +38,7 @@ const firmaAnahtari = email => `${FIRMA_ANAHTAR}:${encodeURIComponent(String(ema
const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocus }) => {
export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocus, now = null }) => {
const { userProfile, mutateTender, savingCount, authUser, tenders } = useApp();
const tender = resolveCurrentTender(selectedTender,tenders);
const storedDraft = tender?.bidDraft || {};
@@ -148,9 +148,13 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
const turCeliskisi = !['birim','goturu'].includes(teklifTuru) || kaynakTeklifTuru.status === 'conflict' || (kaynakTeklifTuru.type && teklifTuru !== kaynakTeklifTuru.type);
const sureEksik = !Number.isInteger(Number(gecerlilikGun)) || !(Number(gecerlilikGun) > 0) || kaynakGecerlilik.status === 'conflict' || (kaynakGecerlilik.days && Number(gecerlilikGun) < kaynakGecerlilik.days);
const [deadlineNow,setDeadlineNow] = useState(() => new Date());
useEffect(() => {const timer=setInterval(()=>setDeadlineNow(new Date()),30000);return ()=>clearInterval(timer);},[]);
const deadlineProblem = preparationDeadlineProblem(tender,deadlineNow);
const [deadlineNow,setDeadlineNow] = useState(() => now || new Date());
useEffect(() => {
if (now) { setDeadlineNow(now); return undefined; }
const timer=setInterval(()=>setDeadlineNow(new Date()),30000);
return ()=>clearInterval(timer);
},[now]);
const deadlineProblem = preparationDeadlineProblem(tender,now || deadlineNow);
const kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,healthPortfolio:userProfile.scrapedCapabilities?.healthPortfolio || [],deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn});
const analizEksik = analysisStatusBlocksPreparation(effectiveResults.sourceReview?.status);
const hazir = !deadlineProblem && eksikler.length === 0 && toplam > 0 && !kalemEksik && !turCeliskisi && !sureEksik && !kaynakKontrolGerekli && kontrolEksikleri.length === 0 && lotSelection.errors.length === 0 && !analizEksik && !sourceOptions.needsSelection;
@@ -189,7 +193,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
const session = getToken();
setHata(null);
try {
const initialDeadlineProblem=preparationDeadlineProblem(tender);
const initialDeadlineProblem=preparationDeadlineProblem(tender,now || new Date());
if(initialDeadlineProblem)throw new Error(initialDeadlineProblem);
if (!hazir) throw new Error('Kalem, miktar, birim, fiyat, firma ve geçerlilik süresindeki eksikleri giderin.');
// Open during the click; async validation must not lose popup activation.
@@ -206,7 +210,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
setVault(currentVault);
const fileErrors = preparationBlockers(lotSelection.rows, documents, checks, currentVault);
if (fileErrors.length) throw new Error(fileErrors.join(' '));
const currentDeadlineProblem=preparationDeadlineProblem(tender);
const currentDeadlineProblem=preparationDeadlineProblem(tender,now || new Date());
if(currentDeadlineProblem)throw new Error(currentDeadlineProblem);
const f = formVerisi();
if (tip === 'excel') {
@@ -219,7 +223,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
setVault(finalVault);
const finalErrors=preparationBlockers(lotSelection.rows,documents,checks,finalVault);
if(finalErrors.length)throw new Error(finalErrors.join(' '));
const finalDeadlineProblem=preparationDeadlineProblem(tender);
const finalDeadlineProblem=preparationDeadlineProblem(tender,now || new Date());
if(finalDeadlineProblem)throw new Error(finalDeadlineProblem);
downloadBidWorkbook(buffer,tender.ikn);
printWindow.current?.close();
+13 -3
View File
@@ -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 }) => {
</div>
{ekapUrl && (
<div className={`rounded-lg p-2.5 flex gap-2 border ${bilgi?.ekapSekme === 'engellendi' ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}>
<div className={`rounded-lg p-2.5 flex gap-2 border ${bilgi?.ekapSekme === 'engellendi' || bilgi?.manualAction ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}>
<ExternalLink className="w-3.5 h-3.5 text-blue-600 shrink-0 mt-0.5" />
<p className="text-[11px] text-slate-700 leading-relaxed">
{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: </>}
<a href={ekapUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-bold hover:underline">
EKAP doküman sayfasını aç
</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.</>}
</p>
</div>
)}
+6 -2
View File
@@ -11,7 +11,7 @@ export const PwaSettings = () => {
return <PwaPanel key={`${authUser?.email}:${getToken()}`}/>;
};
const PwaPanel = () => {
const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow } = useApp();
const { userProfile, authUser, tenders, reminderSettings, saveReminderSettings, reminderSaving, reminderError, requestPushPermission, notify, isQuietNow, offlineQueueSize } = useApp();
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
const [onbellekAdi, setOnbellekAdi] = useState('');
@@ -156,6 +156,10 @@ const PwaPanel = () => {
<span>Önbelleğe alınmış istek:</span>
<strong className="text-slate-900">{onbellekSayisi == null ? '—' : `${onbellekSayisi} kayıt`}</strong>
</p>
<p className="flex justify-between">
<span>Gönderim bekleyen çevrimdışı kayıt:</span>
<strong className="text-slate-900">{offlineQueueSize}</strong>
</p>
<p className="flex justify-between">
<span>Uygulama sürümü önbelleği:</span>
<strong className="text-emerald-700">{onbellekAdi || '—'}</strong>
@@ -169,7 +173,7 @@ const PwaPanel = () => {
{cacheLoading ? 'Kontrol ediliyor…' : 'Önbellek durumunu kontrol et'}
</button>
{cacheError && <p role="alert" className="text-xs text-red-700">{cacheError}</p>}
<p className="text-xs text-slate-600">Bağlantı durumu sunucuya erişimi garanti etmez. Çevrimdışı değişiklikler otomatik gönderim kuyruğuna alınmaz; kayıt sonucunu kontrol edin.</p>
<p className="text-xs text-slate-600">Bağlantı kesildiğinde ihale hazırlık değişiklikleri hesap kapsamlı kuyruğa alınır ve bağlantı gelince yeniden gönderilir. Belge yükleme ve dış sağlayıcı işlemleri çevrimdışı başlatılamaz.</p>
</div>
{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}
+36 -1
View File
@@ -15,6 +15,7 @@ import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender, tender
import { getTenderStates, saveTenderState } from '../services/account';
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
import { istanbulDateKey } from '../services/tenderCalendar';
import { enqueueTenderMutation, offlineMutationCount, replayOfflineMutations } from '../services/offlineMutationQueue.js';
const AppContext = createContext();
@@ -211,12 +212,46 @@ export const AppProvider = ({ children }) => {
const inFlight = useRef(new Set());
const [saveError, setSaveError] = useState(null);
const [savingCount, setSavingCount] = useState(0);
const [offlineQueueSize, setOfflineQueueSize] = useState(0);
const replayOfflineQueue = async (owner) => {
if (!owner || (typeof navigator !== 'undefined' && !navigator.onLine)) return;
const result = await replayOfflineMutations(owner, async item => {
if (item.kind !== 'tender-state') throw new Error('Çevrimdışı kayıt türü desteklenmiyor.');
const record = await saveTenderState(item.key, item.patch, item.snapshot, item.version);
if (getStoredUser()?.email !== owner) throw new Error('Oturum değişti; kuyruk bekletildi.');
savedTenderStates.current = { ...savedTenderStates.current, [item.key]: record };
setTenders(prev => applyTenderStates(prev, savedTenderStates.current));
return record;
});
setOfflineQueueSize(offlineMutationCount(owner));
if (result.error) setSaveError(`Çevrimdışı kayıt gönderilemedi: ${result.error.message}`);
else if (result.sent > 0) setSaveError(null);
return result;
};
useEffect(() => {
const owner = authUser?.email;
if (!owner || !veriSenkron) { setOfflineQueueSize(0); return undefined; }
setOfflineQueueSize(offlineMutationCount(owner));
const reconnect = () => { void replayOfflineQueue(owner); };
window.addEventListener('online', reconnect);
if (typeof navigator === 'undefined' || navigator.onLine) void replayOfflineQueue(owner);
return () => window.removeEventListener('online', reconnect);
}, [authUser?.email, veriSenkron]);
const mutateTender = async (tenderId, patch) => {
const t = tenders.find(t => t.id === tenderId);
if (!t || !veriSenkron || !authUser) { setSaveError('Hesap verileri yüklenmeden kayıt yapılamaz.'); return false; }
const key = tenderStateKey(t);
if (inFlight.current.has(key)) { setSaveError('Bu ihale için önceki kayıt sürüyor. Tamamlandıktan sonra tekrar deneyin.'); return false; }
const owner = authUser.email;
if (typeof navigator !== 'undefined' && navigator.onLine === false) {
try {
enqueueTenderMutation(owner, { key, patch, snapshot: tenderSnapshot(t), version: savedTenderStates.current[key]?.version || 0 });
setOfflineQueueSize(offlineMutationCount(owner));
setTenders(prev => prev.map(row => tenderStateKey(row) === key ? { ...row, ...patch } : row));
setSaveError('Çevrimdışı değişiklik kuyruğa alındı; bağlantı gelince sunucuya gönderilecek.');
return true;
} catch (error) { setSaveError(error.message); return false; }
}
const session = getToken();
inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null);
try {
@@ -906,7 +941,7 @@ export const AppProvider = ({ children }) => {
planYenile,
kotaUyarisi,
setKotaUyarisi,
veriSenkron, saveError, savingCount, mutateTender
veriSenkron, saveError, savingCount, mutateTender, offlineQueueSize
}}>
{children}
</AppContext.Provider>
+127
View File
@@ -0,0 +1,127 @@
const PREFIX = 'ihaleist_offline_mutations:';
const LIMIT = 100;
const activeReplays = new Map();
const ownerKey = owner => `${PREFIX}${encodeURIComponent(String(owner || '').trim().toLowerCase())}`;
function readStorage(owner) {
if (typeof localStorage === 'undefined' || !String(owner || '').trim()) return [];
try {
const parsed = JSON.parse(localStorage.getItem(ownerKey(owner)) || '[]');
return Array.isArray(parsed) ? parsed.filter(item => item && typeof item === 'object') : [];
} catch { return []; }
}
function writeStorage(owner, rows) {
if (typeof localStorage === 'undefined' || !String(owner || '').trim()) return false;
try {
localStorage.setItem(ownerKey(owner), JSON.stringify(rows));
return true;
} catch { return false; }
}
export function offlineMutationCount(owner) {
return readStorage(owner).length;
}
export function listOfflineMutations(owner) {
return readStorage(owner).map(item => ({ ...item }));
}
/**
* Adds a tender-state write to the account-scoped durable queue. Consecutive
* changes for the same tender are merged so reconnecting never replays stale
* intermediate drafts. Tokens and document bytes are deliberately excluded.
*/
export function enqueueTenderMutation(owner, { key, patch, snapshot, version }) {
if (!String(owner || '').trim() || !String(key || '').trim() || !patch || typeof patch !== 'object' || Array.isArray(patch)) {
throw new Error('Çevrimdışı kayıt için ihale ve değişiklik bilgisi gerekli.');
}
const rows = readStorage(owner);
const index = rows.findIndex(item => item.kind === 'tender-state' && item.key === key);
const previous = index >= 0 ? rows[index] : null;
if (!previous && rows.length >= LIMIT) {
throw new Error('Çevrimdışı kayıt kuyruğu dolu. Bağlantı kurulduktan sonra yeniden deneyin.');
}
const next = {
id: previous?.id || `offline-${crypto.randomUUID()}`,
kind: 'tender-state',
key,
patch: { ...(previous?.patch || {}), ...patch },
snapshot: snapshot && typeof snapshot === 'object' ? snapshot : {},
version: Number.isInteger(version) && version >= 0 ? version : 0,
createdAt: previous?.createdAt || new Date().toISOString(),
updatedAt: new Date().toISOString(),
attempts: previous?.attempts || 0,
revision: (Number.isInteger(previous?.revision) ? previous.revision : 0) + 1,
};
if (index >= 0) rows[index] = next; else rows.push(next);
if (!writeStorage(owner, rows)) throw new Error('Çevrimdışı kayıt kuyruğu yazılamadı.');
return next;
}
export function removeOfflineMutation(owner, id) {
const rows = readStorage(owner);
return writeStorage(owner, rows.filter(item => item.id !== id));
}
export function markOfflineMutationAttempt(owner, id) {
const rows = readStorage(owner);
const next = rows.map(item => item.id === id ? { ...item, attempts: (item.attempts || 0) + 1, updatedAt: new Date().toISOString() } : item);
writeStorage(owner, next);
return next.find(item => item.id === id) || null;
}
function acknowledgeOfflineMutation(owner, item, record) {
const rows = readStorage(owner);
const index = rows.findIndex(row => row.id === item.id);
if (index < 0) return true;
const current = rows[index];
if ((current.revision || 0) === (item.revision || 0)) {
rows.splice(index, 1);
} else {
const nextVersion = Number(record?.version);
rows[index] = {
...current,
version: Number.isInteger(nextVersion) && nextVersion >= 0 ? nextVersion : current.version,
};
}
return writeStorage(owner, rows);
}
async function runReplay(owner, send) {
const rows = listOfflineMutations(owner);
const result = { sent: 0, pending: rows.length, error: null };
for (const item of rows) {
markOfflineMutationAttempt(owner, item.id);
try {
const record = await send(item);
if (!acknowledgeOfflineMutation(owner, item, record)) {
throw new Error('Çevrimdışı kayıt kuyruğu güncellenemedi.');
}
result.sent += 1;
} catch (error) {
result.error = error;
break;
}
}
result.pending = offlineMutationCount(owner);
return result;
}
/** Replay in order; concurrent reconnect signals share one replay operation. */
export function replayOfflineMutations(owner, send) {
const replayKey = ownerKey(owner);
const active = activeReplays.get(replayKey);
if (active) return active;
const replay = runReplay(owner, send).finally(() => {
if (activeReplays.get(replayKey) === replay) activeReplays.delete(replayKey);
});
activeReplays.set(replayKey, replay);
return replay;
}
export function clearOfflineMutations(owner) {
if (typeof localStorage === 'undefined') return false;
try { localStorage.removeItem(ownerKey(owner)); return true; } catch { return false; }
}
+14 -3
View File
@@ -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.');
+2
View File
@@ -27,6 +27,8 @@ try {
evidence.checks.signInStatus = response?.status();
await page.getByRole('button', { name: 'Test hesabıyla gir' }).click();
await page.waitForLoadState('networkidle');
const hideExpired = page.getByLabel('Süresi geçenleri gizle', { exact: true });
if (await hideExpired.isChecked()) await hideExpired.uncheck();
evidence.checks.visibleCities = await Promise.all(['Giresun', 'Kayseri', 'Akdeniz', 'Aksaray', 'Tepecik'].map(async city => ({ city, count: await page.getByText(city, { exact: false }).count() })));
if (evidence.checks.visibleCities.some(item => item.count < 1)) throw new Error('Beş sentetik şehirden biri görünmedi.');
await page.getByRole('button', { name: 'Günlük Bülten', exact: true }).click();
+2 -2
View File
@@ -167,7 +167,7 @@ const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{targ
const query=new URL(req.url,'http://localhost').searchParams;
qaFiles=query.has('file')?[{...qaFile}]:null;
const selected=query.has('ready')?readyBid(tenders[0],qaFiles?.[0]):tenders[0];
if(selected.bidDraft){const summary=preparationSummary(selected,{loading:false,error:null,documents:qaFiles || []});if(summary.issues.length)throw new Error('QA ready fixture: '+JSON.stringify(summary.issues));}
if(selected.bidDraft){const summary=preparationSummary(selected,{loading:false,error:null,documents:qaFiles || []},new Date('2026-09-06T09:00:00Z'));if(summary.issues.length)throw new Error('QA ready fixture: '+JSON.stringify(summary.issues));}
const html=`<html lang="tr"><head><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="root"></div><script type="module">
import React from '/node_modules/.vite/deps/react.js';
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.js';
@@ -186,7 +186,7 @@ const ui=await createServer({server:{host:'127.0.0.1',port:0,proxy:{'/api':{targ
React.createElement('button',{onClick:()=>control('release')},'Belge yanıtını serbest bırak'),
React.createElement('p',{role:'status'},status),
React.createElement('button',{onClick:()=>setTenders([{...selected,id:'qa-rotated-source',baslik:'QA Güncellenmiş alım',kazananFirma:'QA Kazanan'}])},'Aynı İKN için yeni kayıt ID ve kazanan yayımla'),
React.createElement(BidPackageBuilder,{tender:selected}));}
React.createElement(BidPackageBuilder,{tender:selected,now:new Date('2026-09-06T09:00:00Z')}));}
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(AppProvider,{},React.createElement(Probe)));
</script></body></html>`;
res.setHeader('Content-Type','text/html; charset=utf-8');res.end(await server.transformIndexHtml('/qa-source-update',html));return;
+27
View File
@@ -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);
}
}
});
+117
View File
@@ -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' },
],
}),
];
+13 -1
View File
@@ -20,7 +20,7 @@ const request=async(path,method='GET',body,auth=token)=>{
before(async()=>{
const socket=createServer();await new Promise(r=>socket.listen(0,'127.0.0.1',r));const port=socket.address().port;await new Promise(r=>socket.close(r));
base='http://127.0.0.1:'+port;
child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'});
child=spawn(process.execPath,['server/index.js'],{cwd:new URL('..',import.meta.url),env:{...process.env,NODE_ENV:'test',APP_RELEASE:'0123456789abcdef',CORS_ORIGINS:'https://client.example,https://admin.example',TELEGRAM_BOT_TOKEN:'',PORT:String(port),IHALEIST_USER_DATA_DIR:dir,IHALEIST_SHARED_DATA_DIR:join(dir,'shared'),UYUM_GOMME:'0'},stdio:'ignore'});
for(let i=0;i<100;i++){try{await fetch(base+'/api/auth/google/config');return;}catch{await new Promise(r=>setTimeout(r,50));}}
throw new Error('QA API did not start');
});
@@ -55,6 +55,18 @@ test('readiness endpoint checks persistent data directories',async()=>{
assert.equal(response.data.checks.userData.ok,true);
assert.equal(response.data.checks.sharedData.ok,true);
});
test('version endpoint identifies the running release and frontend asset',async()=>{
const response=await request('/api/version','GET',null,null);
assert.equal(response.status,200);
assert.equal(response.data.ok,true);
assert.equal(response.data.release,'0123456789abcdef');
assert.equal(response.data.version,'1.0.0');
if(response.data.frontendAsset){
assert.match(response.data.frontendAsset.path,/^\/assets\/.+\.js$/);
assert.match(response.data.frontendAsset.sha256,/^[a-f0-9]{64}$/);
assert.ok(response.data.frontendAsset.bytes>0);
}
});
test('HTTP documents and API keys expose empty successful state, not endless load errors',async()=>{
const docs=await request('/api/me/documents');assert.equal(docs.status,200);assert.deepEqual(docs.data.belgeler,[]);
const keys=await request('/api/me/apikeys');assert.equal(keys.status,200);assert.deepEqual(keys.data.anahtarlar,[]);
+127 -40
View File
@@ -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;
+145
View File
@@ -0,0 +1,145 @@
// D09 read-only production probe. It checks public configuration, source reads
// and unauthenticated guards. It never signs in, sends a message, creates a
// checkout, calls an AI model, changes data or submits a tender.
import { mkdir, writeFile } from 'node:fs/promises';
import { dirname } from 'node:path';
const base = String(process.argv[2] || 'https://ihaleist.com').replace(/\/$/, '');
const evidencePath = process.env.IHALEIST_LIVE_INTEGRATIONS_EVIDENCE_PATH
|| 'docs/quality/evidence/qa-live-d09-integrations.json';
const timeoutMs = 45_000;
async function request(path, options = {}) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${base}${path}`, {
...options,
signal: controller.signal,
headers: {
'User-Agent': 'ihaleist-live-integrations-probe/1.0',
...(options.headers || {}),
},
});
const contentType = response.headers.get('content-type') || '';
const body = await response.text();
let json = null;
if (contentType.includes('json')) {
try { json = JSON.parse(body); } catch { /* malformed JSON stays visible as a failed check */ }
}
return {
path,
method: options.method || 'GET',
status: response.status,
contentType,
json,
textChars: body.length,
error: response.ok ? null : String(json?.error?.message || json?.error || body || `HTTP ${response.status}`).slice(0, 300),
};
} catch (error) {
return { path, method: options.method || 'GET', status: 0, contentType: '', json: null, textChars: 0, error: error?.message || String(error) };
} finally {
clearTimeout(timer);
}
}
const raw = {};
for (const [name, path, options] of [
['google', '/api/auth/google/config'],
['tcmb', '/api/exchange-rates'],
['documents', '/api/documents/config'],
['ai', '/api/ai/config'],
['billing', '/api/billing/plans'],
['telegram', '/api/health/takip'],
['scheduler', '/api/health/scheduler'],
['items', '/api/health/kalem'],
['embedding', '/api/health/uyum'],
['bik', '/api/stats'],
['history', '/api/history/stats'],
['resultsRss', '/api/feeds/sonuc.rss?limit=1'],
['protectedRss', '/api/feeds/tenders.rss'],
['manifest', '/manifest.webmanifest'],
['serviceWorker', '/sw.js'],
['ted', '/api/ted?mod=turkiye&since=20990101&limit=1'],
['kik', '/api/kik/search?limit=1'],
['okas', '/api/okas/ara?q=hemogram'],
['mcpGuard', '/mcp', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'initialize', params: {} }) }],
]) {
raw[name] = await request(path, options);
}
const guardPaths = {
authSession: ['/api/auth/me'],
documents: ['/api/me/documents'],
documentText: ['/api/documents/text?ikn=2026%2F1634449'],
apiKeys: ['/api/me/apikeys'],
emailConfig: ['/api/notify/email/config'],
plan: ['/api/me/plan'],
subscriptions: ['/api/notify/subscriptions'],
sourceHistory: ['/api/tenders/source-history?ikn=2026%2F1634449'],
companyScan: ['/api/company/scan', { method: 'POST' }],
aiAnalyze: ['/api/ai/analyze', { method: 'POST' }],
checkout: ['/api/billing/checkout', { method: 'POST' }],
emailSend: ['/api/notify/email/test', { method: 'POST' }],
telegramMutation: ['/api/me/telegram', { method: 'POST' }],
notificationRun: ['/api/notify/run', { method: 'POST' }],
};
const guards = {};
for (const [name, [path, options]] of Object.entries(guardPaths)) {
const probe = await request(path, options);
guards[name] = { status: probe.status, passed: [401, 403].includes(probe.status) };
}
function row(id, label, status, evidence, remaining) {
return { id, label, status, evidence, remaining };
}
const googleConfigured = raw.google.status === 200 && Boolean(raw.google.json?.clientId);
const tcmbReady = raw.tcmb.status === 200 && raw.tcmb.json?.success === true
&& Number(raw.tcmb.json?.rates?.USD) > 0 && Number(raw.tcmb.json?.rates?.EUR) > 0;
const aiConfigured = raw.ai.status === 200 && raw.ai.json?.configured === true;
const billingConfigured = raw.billing.status === 200 && raw.billing.json?.configured === true;
const billingWebhookReady = raw.billing.status === 200 && raw.billing.json?.webhookHazir === true;
const telegramConfigured = raw.telegram.status === 200 && raw.telegram.json?.telegram === true;
const kikManualGate = raw.kik.status === 428 && raw.kik.json?.code === 'HUMAN_VERIFICATION_REQUIRED';
const ekapManualGate = raw.okas.status === 428 && raw.okas.json?.code === 'HUMAN_VERIFICATION_REQUIRED';
const integrations = [
row('BIK', 'ilan.gov.tr ihale havuzu', raw.bik.status === 200 && raw.bik.json?.success ? 'passed' : 'blocked', { status: raw.bik.status, source: raw.bik.json?.kaynak || null, loaded: raw.bik.json?.yuklenenIlan ?? null }, 'Güncel liste okuması doğrulandı; veri kalitesi D10/D04 kapsamında izlenir.'),
row('TCMB', 'TCMB döviz satış kurları', tcmbReady ? 'passed' : 'blocked', { status: raw.tcmb.status, bulletinDate: raw.tcmb.json?.bulletinDate || null, source: raw.tcmb.json?.source || null, cached: raw.tcmb.json?.cached ?? null }, tcmbReady ? 'Hata ve eski bülten UX testleri yerel otomasyonda kapsanıyor.' : raw.tcmb.error),
row('GOOGLE', 'Google ile giriş', googleConfigured ? 'configured' : 'blocked', { status: raw.google.status, configured: googleConfigured }, 'Gerçek test hesabıyla giriş, çıkış ve oturum yenileme kabulü gerekli.'),
row('EKAP', 'EKAP arama ve resmi doküman', raw.okas.status === 200 ? 'partial' : ekapManualGate ? 'manual-gate' : 'blocked', { browserAvailable: raw.documents.json?.tarayiciVar ?? null, okasStatus: raw.okas.status, okasCode: raw.okas.json?.code || null, d10DocumentCode: 'HUMAN_VERIFICATION_REQUIRED' }, 'Yetkili kullanıcı güvenlik doğrulamasından sonra beş resmi paketi indirip yüklemeli.'),
row('KIK', 'KİK kurul kararları', raw.kik.status === 200 && raw.kik.json?.success ? 'passed' : kikManualGate ? 'manual-gate' : 'blocked', { status: raw.kik.status, code: raw.kik.json?.code || null, resultCount: raw.kik.json?.results?.length ?? null, error: raw.kik.error }, raw.kik.status === 200 ? 'Bir kararın ayrıntı bağlantısı ayrıca kullanıcı akışında açılmalı.' : kikManualGate ? 'EKAP güvenlik doğrulamasından sonra arama ve karar metni tekrar denenmeli.' : 'Kaynak erişimi düzeltildikten sonra arama ve karar metni tekrar denenmeli.'),
row('TED', 'TED Avrupa ihaleleri', raw.ted.status === 200 && raw.ted.json?.success ? 'passed' : 'blocked', { status: raw.ted.status, totalCount: raw.ted.json?.totalCount ?? null, timedOut: raw.ted.json?.timedOut ?? null, error: raw.ted.error }, raw.ted.status === 200 ? 'Gerçek sonuçlu sorgu ve çeviri, kontrollü AI test bütçesiyle yapılmalı.' : 'TED kaynağı tekrar denenmeli.'),
row('AI', 'AI analiz ve teklif yardımcıları', aiConfigured ? 'configured' : 'blocked', { status: raw.ai.status, configured: aiConfigured, providerCount: Array.isArray(raw.ai.json?.providers) ? raw.ai.json.providers.length : 0 }, 'Yetkili test hesabı ve incelemesi tamamlanmış test belgesiyle analiz, soru, taslak ve denetim zinciri gerekli.'),
row('STRIPE', 'Stripe plan, ödeme ve webhook', billingConfigured && billingWebhookReady ? 'configured' : 'blocked', { status: raw.billing.status, checkoutConfigured: billingConfigured, webhookConfigured: billingWebhookReady, planCount: Array.isArray(raw.billing.json?.plans) ? raw.billing.json.plans.length : 0 }, 'Yalnız Stripe test ortamında başarılı/başarısız ödeme, webhook tekrarı ve iptal senaryoları gerekli.'),
row('EMAIL', 'E-posta bildirimleri', guards.emailConfig?.passed ? 'guarded' : 'blocked', { unauthenticatedStatus: guards.emailConfig?.status ?? null }, 'Yetkili test hesabında yapılandırma okunmalı; izinli test alıcısına tek gönderim ayrıca onaylanmalı.'),
row('TELEGRAM', 'Telegram bağlantısı ve bildirim', telegramConfigured ? 'configured' : 'blocked', { status: raw.telegram.status, configured: telegramConfigured }, 'Test sohbetinde bağlama kodu ve tek test mesajı için açık gönderim yetkisi gerekli.'),
row('PWA', 'PWA manifest ve offline worker', raw.manifest.status === 200 && raw.serviceWorker.status === 200 ? 'passed' : 'blocked', { manifestStatus: raw.manifest.status, serviceWorkerStatus: raw.serviceWorker.status }, 'Gerçek cihazda kurulum, offline düzenleme ve yeniden bağlanma kabulü ayrıca izlenir.'),
row('API', 'API anahtarı, RSS ve MCP', raw.protectedRss.status === 401 && raw.mcpGuard.status === 401 && guards.apiKeys?.passed ? 'guarded' : 'blocked', { apiKeyGuard: guards.apiKeys?.status ?? null, rssGuard: raw.protectedRss.status, mcpGuard: raw.mcpGuard.status, publicResultsRssStatus: raw.resultsRss.status }, 'Yetkili test hesabında anahtar oluşturma, RSS/MCP kullanma ve iptal zinciri gerekli.'),
row('SCHEDULER', 'Zeyilname/sonuç zamanlayıcısı', raw.scheduler.status === 200 && raw.scheduler.json?.success ? 'observable' : 'blocked', { status: raw.scheduler.status, browserAvailable: raw.scheduler.json?.tarayiciVar ?? null, resultScanActive: raw.scheduler.json?.sonucTaramasi?.aktif ?? null, resultScanRunning: raw.scheduler.json?.sonucTaramasi?.calisiyor ?? null, directProcurementActive: raw.scheduler.json?.dogrudanTemin?.aktif ?? null, directProcurementRunning: raw.scheduler.json?.dogrudanTemin?.calisiyor ?? null }, 'Yetkili test kaydıyla zeyilname/sonuç olayı ve tekrar engeli doğrulanmalı.'),
];
const result = {
checkedAt: new Date().toISOString(),
base,
readOnly: true,
scope: 'Yapılandırma, public kaynak okuması ve kimlik doğrulama kapıları denetlendi; giriş, veri değişikliği, AI üretimi, ödeme, mesaj veya teklif gönderimi yapılmadı.',
integrations,
guards,
summary: {
total: integrations.length,
passed: integrations.filter(({ status }) => status === 'passed').length,
configured: integrations.filter(({ status }) => status === 'configured').length,
guarded: integrations.filter(({ status }) => status === 'guarded').length,
observable: integrations.filter(({ status }) => status === 'observable').length,
manualGate: integrations.filter(({ status }) => status === 'manual-gate').length,
blocked: integrations.filter(({ status }) => status === 'blocked').length,
protectedGuardsPassed: Object.values(guards).filter(({ passed }) => passed).length,
protectedGuardsTotal: Object.keys(guards).length,
},
};
await mkdir(dirname(evidencePath), { recursive: true });
await writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`);
console.log(JSON.stringify({ checkedAt: result.checkedAt, ...result.summary, statuses: Object.fromEntries(integrations.map(({ id, status }) => [id, status])) }));
+54 -7
View File
@@ -2,6 +2,7 @@
// provider calls are performed. It intentionally fails when a required
// deployment contract is missing.
import { mkdir, writeFile } from 'node:fs/promises';
import crypto from 'node:crypto';
const base = String(process.argv[2] || process.env.IHALEIST_LIVE_URL || 'https://ihaleist.com').replace(/\/$/, '');
const evidencePath = process.env.IHALEIST_LIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-runtime.json';
@@ -13,8 +14,10 @@ const requiredHeaders = [
];
const observedHeaders = [...new Set([...requiredHeaders, 'strict-transport-security'])];
const checks = {};
const expectedRelease = String(process.env.IHALEIST_EXPECTED_RELEASE || '').trim();
const expectedAssetSha256 = String(process.env.IHALEIST_EXPECTED_ASSET_SHA256 || '').trim();
async function probe(path, successField = null) {
async function probe(path, successField = null, captureText = false) {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
@@ -24,10 +27,13 @@ async function probe(path, successField = null) {
contentType: response.headers.get('content-type'),
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
};
if (successField) {
if (successField || captureText) {
const raw = await response.text();
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
result.apiSuccess = result.json?.[successField] === true;
if (captureText) result.text = raw;
if (successField) {
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
result.apiSuccess = result.json?.[successField] === true;
}
}
return result;
} catch (error) {
@@ -37,22 +43,63 @@ async function probe(path, successField = null) {
}
}
checks.home = await probe('/');
async function probeFrontendAsset(publicPath) {
if (!publicPath) return { error: 'Ana sayfada Vite JavaScript asset yolu bulunamadı.' };
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(new URL(publicPath, base), { signal: controller.signal, redirect: 'manual' });
const bytes = Buffer.from(await response.arrayBuffer());
return {
status: response.status,
contentType: response.headers.get('content-type'),
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
path: new URL(publicPath, base).pathname,
bytes: bytes.length,
sha256: crypto.createHash('sha256').update(bytes).digest('hex'),
};
} catch (error) {
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
} finally {
clearTimeout(timer);
}
}
checks.home = await probe('/', null, true);
const assetMatch = checks.home.text?.match(/<script[^>]+src=["']([^"']*\/assets\/[^"']+\.js(?:\?[^"']*)?)["']/i);
delete checks.home.text;
checks.health = await probe('/api/health', 'ok');
// /api/health and /api/ready use the same { ok: true } probe contract. The
// readiness payload also includes a separate `ready` diagnostic field.
checks.ready = await probe('/api/ready', 'ok');
checks.version = await probe('/api/version', 'ok');
// The EKAP document fallback depends on the bundled browser being available;
// check its public capability endpoint without opening a document or mutating
// any account data.
checks.documents = await probe('/api/documents/config', 'tarayiciVar');
const missingHeaders = requiredHeaders.filter(name => [checks.home, checks.health, checks.ready, checks.documents].some(check => check?.headers?.[name] == null));
checks.frontendAsset = await probeFrontendAsset(assetMatch?.[1]);
const release = checks.version?.json?.release;
const releaseValid = typeof release === 'string' && /^[a-f0-9]{7,64}$/i.test(release);
const releaseMatches = !expectedRelease || release === expectedRelease;
const reportedAsset = checks.version?.json?.frontendAsset;
const assetMatchesRuntime = Boolean(reportedAsset?.sha256)
&& reportedAsset.sha256 === checks.frontendAsset?.sha256
&& reportedAsset.path === checks.frontendAsset?.path;
const assetMatchesExpected = !expectedAssetSha256 || checks.frontendAsset?.sha256 === expectedAssetSha256;
const publicChecks = [checks.home, checks.health, checks.ready, checks.version, checks.documents, checks.frontendAsset];
const missingHeaders = requiredHeaders.filter(name => publicChecks.some(check => check?.headers?.[name] == null));
const report = {
base,
readOnly: true,
checkedAt: new Date().toISOString(),
expectedRelease: expectedRelease || null,
expectedAssetSha256: expectedAssetSha256 || null,
checks,
pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && missingHeaders.length === 0,
releaseValid,
releaseMatches,
assetMatchesRuntime,
assetMatchesExpected,
pass: checks.home?.status === 200 && checks.health?.status === 200 && checks.health.apiSuccess === true && checks.ready?.status === 200 && checks.ready.apiSuccess === true && checks.version?.status === 200 && checks.version.apiSuccess === true && checks.documents?.status === 200 && checks.documents.apiSuccess === true && checks.frontendAsset?.status === 200 && releaseValid && releaseMatches && assetMatchesRuntime && assetMatchesExpected && missingHeaders.length === 0,
missingHeaders,
};
+10
View File
@@ -0,0 +1,10 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
test('Nginx proxies MCP JSON-RPC requests to the authenticated Node endpoint', () => {
const config = readFileSync(new URL('../nginx.conf', import.meta.url), 'utf8');
const block = /location\s*=\s*\/mcp\s*\{([\s\S]*?)\n\s*\}/.exec(config)?.[1] || '';
assert.match(block, /proxy_pass\s+http:\/\/127\.0\.0\.1:3001;/);
assert.match(block, /client_max_body_size\s+1m;/);
});
+91
View File
@@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
clearOfflineMutations,
enqueueTenderMutation,
listOfflineMutations,
offlineMutationCount,
replayOfflineMutations,
} from '../src/services/offlineMutationQueue.js';
const storage = new Map();
globalThis.localStorage = {
getItem: key => storage.has(key) ? storage.get(key) : null,
setItem: (key, value) => storage.set(key, String(value)),
removeItem: key => storage.delete(key),
};
test('offline tender mutations are account scoped and merged by tender', async () => {
clearOfflineMutations('qa@example.test');
enqueueTenderMutation('qa@example.test', { key: '2026/1', patch: { takipte: true }, snapshot: { ikn: '2026/1' }, version: 2 });
enqueueTenderMutation('qa@example.test', { key: '2026/1', patch: { kanbanStatus: 'teklif_hazirlaniyor' }, snapshot: { ikn: '2026/1' }, version: 2 });
enqueueTenderMutation('other@example.test', { key: '2026/1', patch: { takipte: true }, snapshot: { ikn: '2026/1' }, version: 0 });
assert.equal(offlineMutationCount('qa@example.test'), 1);
assert.deepEqual(listOfflineMutations('qa@example.test')[0].patch, { takipte: true, kanbanStatus: 'teklif_hazirlaniyor' });
assert.equal(offlineMutationCount('other@example.test'), 1);
clearOfflineMutations('qa@example.test'); clearOfflineMutations('other@example.test');
});
test('replay removes acknowledged rows and retains the first failed row', async () => {
clearOfflineMutations('replay@example.test');
enqueueTenderMutation('replay@example.test', { key: 'a', patch: { takipte: true }, snapshot: {}, version: 0 });
enqueueTenderMutation('replay@example.test', { key: 'b', patch: { takipte: true }, snapshot: {}, version: 0 });
const sent = [];
const first = await replayOfflineMutations('replay@example.test', async item => { sent.push(item.key); });
assert.deepEqual(sent, ['a', 'b']);
assert.equal(first.pending, 0);
enqueueTenderMutation('replay@example.test', { key: 'c', patch: { takipte: true }, snapshot: {}, version: 0 });
enqueueTenderMutation('replay@example.test', { key: 'd', patch: { takipte: true }, snapshot: {}, version: 0 });
const second = await replayOfflineMutations('replay@example.test', async item => { if (item.key === 'c') throw new Error('network'); });
assert.equal(second.sent, 0);
assert.equal(listOfflineMutations('replay@example.test')[0].key, 'c');
assert.equal(listOfflineMutations('replay@example.test')[0].attempts, 1);
clearOfflineMutations('replay@example.test');
});
test('concurrent reconnect signals send each queued mutation once', async () => {
clearOfflineMutations('concurrent@example.test');
enqueueTenderMutation('concurrent@example.test', { key: 'a', patch: { takipte: true }, snapshot: {}, version: 0 });
let sends = 0;
let release;
const gate = new Promise(resolve => { release = resolve; });
const send = async () => { sends += 1; await gate; return { version: 1 }; };
const first = replayOfflineMutations('concurrent@example.test', send);
const second = replayOfflineMutations('concurrent@example.test', send);
release();
const [a, b] = await Promise.all([first, second]);
assert.equal(sends, 1);
assert.deepEqual(a, b);
assert.equal(offlineMutationCount('concurrent@example.test'), 0);
});
test('a mutation added during replay is retained with the acknowledged server version', async () => {
clearOfflineMutations('during-replay@example.test');
enqueueTenderMutation('during-replay@example.test', { key: 'a', patch: { takipte: true }, snapshot: {}, version: 3 });
const first = await replayOfflineMutations('during-replay@example.test', async item => {
enqueueTenderMutation('during-replay@example.test', { key: 'a', patch: { kanbanStatus: 'teklif_hazirlaniyor' }, snapshot: {}, version: item.version });
return { version: 4 };
});
assert.equal(first.pending, 1);
const retained = listOfflineMutations('during-replay@example.test')[0];
assert.equal(retained.version, 4);
assert.deepEqual(retained.patch, { takipte: true, kanbanStatus: 'teklif_hazirlaniyor' });
const versions = [];
await replayOfflineMutations('during-replay@example.test', async item => { versions.push(item.version); return { version: 5 }; });
assert.deepEqual(versions, [4]);
assert.equal(offlineMutationCount('during-replay@example.test'), 0);
});
test('a full queue rejects a new tender instead of silently discarding an older change', () => {
clearOfflineMutations('full@example.test');
for (let index = 0; index < 100; index += 1) {
enqueueTenderMutation('full@example.test', { key: String(index), patch: { takipte: true }, snapshot: {}, version: 0 });
}
assert.throws(
() => enqueueTenderMutation('full@example.test', { key: 'overflow', patch: { takipte: true }, snapshot: {}, version: 0 }),
/kuyruğu dolu/,
);
assert.equal(offlineMutationCount('full@example.test'), 100);
assert.equal(listOfflineMutations('full@example.test')[0].key, '0');
clearOfflineMutations('full@example.test');
});
+29
View File
@@ -0,0 +1,29 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { deployedRelease, frontendAssetInfo } from '../server/release-info.mjs';
test('release metadata rejects missing build markers', () => {
assert.equal(deployedRelease({}), null);
assert.equal(deployedRelease({ APP_RELEASE: 'unknown' }), null);
assert.equal(deployedRelease({ SOURCE_COMMIT: 'abc1234' }), 'abc1234');
assert.equal(deployedRelease({ APP_RELEASE: 'release-a', SOURCE_COMMIT: 'release-b' }), 'release-a');
});
test('frontend asset metadata hashes the script referenced by index.html', () => {
const root = mkdtempSync(join(tmpdir(), 'ihaleist-release-'));
try {
mkdirSync(join(root, 'assets'));
writeFileSync(join(root, 'index.html'), '<script type="module" src="/assets/index-test.js"></script>');
writeFileSync(join(root, 'assets/index-test.js'), 'console.log("release");\n');
assert.deepEqual(frontendAssetInfo([root]), {
path: '/assets/index-test.js',
sha256: 'ed81365d3ce25c2e499a43c7d91c72cbc05c6475a19423f18fc3917c96857e80',
bytes: 24,
});
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+31
View File
@@ -0,0 +1,31 @@
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,
});
});
test('KIK HTTP 428 is also exposed as the same manual verification gate', () => {
const result = sourceFailure(new Error('KİK sunucusu isteği reddetti (HTTP 428).'));
assert.equal(result.status, 428);
assert.equal(result.body.code, 'HUMAN_VERIFICATION_REQUIRED');
assert.equal(result.body.manualAction, true);
});
+2
View File
@@ -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});