Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c68070ddf9 | ||
|
|
6fda6764c3 | ||
|
|
6c32581952 | ||
|
|
26b250735c | ||
|
|
e5e1b12185 | ||
|
|
242e7d8c2e | ||
|
|
0bbfed321a | ||
|
|
4d191a07b1 | ||
|
|
a7495a1fe6 | ||
|
|
4e94a77019 | ||
|
|
b578c2d3ba |
@@ -28,6 +28,11 @@ RUN npm init -y >/dev/null \
|
|||||||
&& npx playwright install --with-deps chromium \
|
&& npx playwright install --with-deps chromium \
|
||||||
&& npm cache clean --force
|
&& 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
|
# Frontend
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
COPY --from=build /app/nginx.conf /etc/nginx/sites-available/default
|
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
|
COPY --from=build /app/server ./server
|
||||||
# Backend, ortak doğrulama ve iş kurallarını src/services altından import eder.
|
# Backend, ortak doğrulama ve iş kurallarını src/services altından import eder.
|
||||||
COPY --from=build /app/src ./src
|
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ı)
|
# Kalıcı veri (Coolify'da /app/server/data volume olarak bağlı)
|
||||||
RUN mkdir -p /app/server/data
|
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).
|
||||||
@@ -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.
|
- `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.
|
- 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.
|
- `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
|
## 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.
|
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.
|
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
|
## 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.
|
Ö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,
|
"printButtonHidden": true,
|
||||||
"overviewStatus": 200,
|
"overviewStatus": 200,
|
||||||
"overviewFrames": 3,
|
"overviewFrames": 3,
|
||||||
|
"documentOwnerInPreparationOverview": true,
|
||||||
"mobileStatus": 200,
|
"mobileStatus": 200,
|
||||||
"mobileOverflow": {
|
"mobileOverflow": {
|
||||||
"viewport": 390,
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,124 +1,644 @@
|
|||||||
{
|
{
|
||||||
"checkedAt": "2026-09-06T17:39:49.038Z",
|
"checkedAt": "2026-09-15T14:45:21.910Z",
|
||||||
"base": "https://ihaleist.com",
|
"base": "https://ihaleist.com",
|
||||||
"readOnly": true,
|
"readOnly": true,
|
||||||
"listCount": 8224,
|
"listProbe": {
|
||||||
"tenders": [
|
"status": 200,
|
||||||
|
"success": true
|
||||||
|
},
|
||||||
|
"listCount": 10898,
|
||||||
|
"selectedCount": 5,
|
||||||
|
"announcementPassCount": 5,
|
||||||
|
"officialDocumentPackagePassCount": 0,
|
||||||
|
"positiveCount": 0,
|
||||||
|
"pass": false,
|
||||||
|
"blockers": [
|
||||||
{
|
{
|
||||||
"ikn": "2026/1582991",
|
"ikn": "2026/1634449",
|
||||||
"scenario": "mikroskop",
|
"stage": "officialDocumentPackage",
|
||||||
"id": "ilan-2204986",
|
"status": 428,
|
||||||
"title": "Mikroskop satın alınacaktır",
|
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||||
"institution": "GİRESUN EĞİTİM VE ARAŞTIRMA HASTANESİ BAŞHEKİMLİĞİ",
|
"retryable": false,
|
||||||
"city": "Giresun",
|
"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."
|
||||||
"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/1534665",
|
"ikn": "2026/1676347",
|
||||||
"scenario": "sarf",
|
"stage": "officialDocumentPackage",
|
||||||
"id": "ilan-2205024",
|
"status": 428,
|
||||||
"title": "26 kalem laboratuvar sarf malzeme alımı",
|
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||||
"institution": "KAYSERİ DEVLET HASTANESİ BAŞHEKİMLİĞİ",
|
"retryable": false,
|
||||||
"city": "Kayseri",
|
"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."
|
||||||
"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/1499061",
|
"ikn": "2026/1667534",
|
||||||
"scenario": "cihaz-servis",
|
"stage": "officialDocumentPackage",
|
||||||
"id": "ilan-2204525",
|
"status": 428,
|
||||||
"title": "Laboratuvar malzemesi alınacaktır",
|
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||||
"institution": "AKDENİZ ÜNİVERSİTESİ REKTÖRLÜĞÜ ÜNİVERSİTE HASTANESİ BAŞ MÜDÜRLÜĞÜ",
|
"retryable": false,
|
||||||
"city": "Antalya",
|
"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."
|
||||||
"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/1573235",
|
"ikn": "2026/1620320",
|
||||||
"scenario": "kismi-teklif",
|
"stage": "officialDocumentPackage",
|
||||||
"id": "ilan-2203314",
|
"status": 428,
|
||||||
"title": "Laboratuvar sarf malzemeleri satın alınacak",
|
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||||
"institution": "AKSARAY ÜNİVERSİTESİ EĞİTİM VE ARAŞTIRMA HASTANESİ",
|
"retryable": false,
|
||||||
"city": "Aksaray",
|
"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."
|
||||||
"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/1523851",
|
"ikn": "2026/1636687",
|
||||||
"scenario": "100-kalem",
|
"stage": "officialDocumentPackage",
|
||||||
"id": "ilan-2200563",
|
"status": 428,
|
||||||
"title": "Laboratuvar malzemesi satın alınacaktır",
|
"code": "HUMAN_VERIFICATION_REQUIRED",
|
||||||
"institution": "İZMİR TEPECİK EĞİTİM VE ARAŞTIRMA HASTANESİ",
|
"retryable": false,
|
||||||
"city": "İzmir",
|
"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."
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"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ı."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
{
|
{
|
||||||
"base": "https://ihaleist.com",
|
"base": "https://ihaleist.com",
|
||||||
"readOnly": true,
|
"readOnly": true,
|
||||||
"checkedAt": "2026-09-06T17:30:38.609Z",
|
"checkedAt": "2026-09-15T14:45:06.693Z",
|
||||||
|
"expectedRelease": "e5e1b12185b98a8ddf9e05a88381a5f0e377d60e",
|
||||||
|
"expectedAssetSha256": "4bca821e3b14ef1e349cd31e312c5027fa8527d17ecc2697852a004a6826eb7e",
|
||||||
"checks": {
|
"checks": {
|
||||||
"home": {
|
"home": {
|
||||||
"status": 200,
|
"status": 200,
|
||||||
@@ -55,8 +57,66 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"apiSuccess": true
|
"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,
|
"pass": true,
|
||||||
"missingHeaders": []
|
"missingHeaders": []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,30 @@
|
|||||||
# Düzeltme planı — uygulama durumu
|
# 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ı.
|
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
@@ -41,11 +41,36 @@ server {
|
|||||||
proxy_connect_timeout 10s;
|
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
|
# Statik varlıklar
|
||||||
location /assets/ {
|
location /assets/ {
|
||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
try_files $uri =404;
|
try_files $uri =404;
|
||||||
expires 30d;
|
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";
|
add_header Cache-Control "public, no-transform";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -7,11 +7,13 @@
|
|||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"qa:browser": "node tests/browser-acceptance.mjs",
|
"qa:browser": "node tests/browser-acceptance.mjs",
|
||||||
|
"qa:asset-hash": "node scripts/asset-fingerprint.mjs",
|
||||||
"qa:live": "node tests/live-runtime-probe.mjs",
|
"qa:live": "node tests/live-runtime-probe.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"server": "node server/index.js",
|
"server": "node server/index.js",
|
||||||
"test": "node --test tests/*.test.mjs",
|
"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": {
|
"dependencies": {
|
||||||
"canvas-confetti": "^1.9.4",
|
"canvas-confetti": "^1.9.4",
|
||||||
|
|||||||
@@ -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
@@ -65,6 +65,8 @@ import { subscribe, list as listSubs, remove as removeSub, deliveryQueue, resolv
|
|||||||
import { bildirimleriIsle } from './notifier.mjs';
|
import { bildirimleriIsle } from './notifier.mjs';
|
||||||
import { scanSite } from './site-scan.mjs';
|
import { scanSite } from './site-scan.mjs';
|
||||||
import { createDetailCache } from './detail-cache.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;
|
// ilan.gov.tr sunucusu ara sertifikayı (GeoTrust TLS RSA CA G1) göndermiyor;
|
||||||
// zinciri tamamlamak için PEM'i yerelden ekliyoruz.
|
// 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 PORT = process.env.PORT || 3001;
|
||||||
const ILAN_BASE = 'https://www.ilan.gov.tr';
|
const ILAN_BASE = 'https://www.ilan.gov.tr';
|
||||||
const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean);
|
const CORS_ORIGINS = String(process.env.CORS_ORIGINS || '').split(',').map(value => value.trim()).filter(Boolean);
|
||||||
|
const RUNTIME_RELEASE = releaseInfo();
|
||||||
|
|
||||||
function readiness() {
|
function readiness() {
|
||||||
const directories = {
|
const directories = {
|
||||||
@@ -661,13 +664,6 @@ function json(res, code, body) {
|
|||||||
res.end(JSON.stringify(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) {
|
function readRaw(req, limit = 1024 * 1024) {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
let buf = '';
|
let buf = '';
|
||||||
@@ -840,7 +836,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const out = await documentUrl(ikn);
|
const out = await documentUrl(ikn);
|
||||||
return json(res, 200, { success: true, ...out });
|
return json(res, 200, { success: true, ...out });
|
||||||
} catch (e) {
|
} 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)
|
// İ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);
|
const parsed = mergeAnnouncementFields(out.metin, out);
|
||||||
return json(res, 200, { success: true, ...out, ...parsed, tarih: out.tarih || parsed.tarih || '', saat: out.saat || parsed.saat || '' });
|
return json(res, 200, { success: true, ...out, ...parsed, tarih: out.tarih || parsed.tarih || '', saat: out.saat || parsed.saat || '' });
|
||||||
} catch (e) {
|
} 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
|
// Doküman metni — sunucu tarafı tarayıcı gerektirir
|
||||||
@@ -865,7 +863,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const out = await documentText(ikn);
|
const out = await documentText(ikn);
|
||||||
return json(res, 200, { success: true, ...out });
|
return json(res, 200, { success: true, ...out });
|
||||||
} catch (e) {
|
} 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') {
|
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);
|
if (urunAramaCache.size > 200) urunAramaCache.delete(urunAramaCache.keys().next().value);
|
||||||
return json(res, 200, { success: true, ...out });
|
return json(res, 200, { success: true, ...out });
|
||||||
} catch (e) {
|
} 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
|
// 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 });
|
urunAramaCache.set(anahtar, { zaman: Date.now(), veri: kalemler });
|
||||||
return json(res, 200, { success: true, kalemler });
|
return json(res, 200, { success: true, kalemler });
|
||||||
} catch (e) {
|
} 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) ---
|
// --- 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()]);
|
const [z, so] = await Promise.all([zeyilnameTara(), sonucTara()]);
|
||||||
return json(res, 200, { success: true, zeyilname: z, sonuc: so });
|
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') {
|
if (url.pathname === '/api/health/kalem') {
|
||||||
return json(res, 200, { success: true, ...kalemStore.kalemSayilari(), depo: kalemStore.available() });
|
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 });
|
return json(res, 200, { success: true, ...out });
|
||||||
} catch (e) {
|
} 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') {
|
if (url.pathname === '/api/kik/karar') {
|
||||||
@@ -1064,7 +1093,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
const metin = await kararMetni(id);
|
const metin = await kararMetni(id);
|
||||||
return json(res, 200, { success: true, id, metin });
|
return json(res, 200, { success: true, id, metin });
|
||||||
} catch (e) {
|
} 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/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') {
|
if (url.pathname === '/api/ready') {
|
||||||
const status = readiness();
|
const status = readiness();
|
||||||
return json(res, status.ready ? 200 : 503, { ok: status.ready, ...status });
|
return json(res, status.ready ? 200 : 503, { ok: status.ready, ...status });
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ const firmaAnahtari = email => `${FIRMA_ANAHTAR}:${encodeURIComponent(String(ema
|
|||||||
|
|
||||||
const bosFirma = () => Object.fromEntries(ALANLAR.map((a) => [a.k, '']));
|
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 { userProfile, mutateTender, savingCount, authUser, tenders } = useApp();
|
||||||
const tender = resolveCurrentTender(selectedTender,tenders);
|
const tender = resolveCurrentTender(selectedTender,tenders);
|
||||||
const storedDraft = tender?.bidDraft || {};
|
const storedDraft = tender?.bidDraft || {};
|
||||||
@@ -148,9 +148,13 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
|
|||||||
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
|
const kalemEksik = teklifTuru === 'birim' && validateBidRows(satirlar).length > 0;
|
||||||
const turCeliskisi = !['birim','goturu'].includes(teklifTuru) || kaynakTeklifTuru.status === 'conflict' || (kaynakTeklifTuru.type && teklifTuru !== kaynakTeklifTuru.type);
|
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 sureEksik = !Number.isInteger(Number(gecerlilikGun)) || !(Number(gecerlilikGun) > 0) || kaynakGecerlilik.status === 'conflict' || (kaynakGecerlilik.days && Number(gecerlilikGun) < kaynakGecerlilik.days);
|
||||||
const [deadlineNow,setDeadlineNow] = useState(() => new Date());
|
const [deadlineNow,setDeadlineNow] = useState(() => now || new Date());
|
||||||
useEffect(() => {const timer=setInterval(()=>setDeadlineNow(new Date()),30000);return ()=>clearInterval(timer);},[]);
|
useEffect(() => {
|
||||||
const deadlineProblem = preparationDeadlineProblem(tender,deadlineNow);
|
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 kontrolEksikleri = preparationBlockers(lotSelection.rows, documents, checks, {...vault,healthPortfolio:userProfile.scrapedCapabilities?.healthPortfolio || [],deadlineDate:tenderDateKey(tender),tenderIkn:tender.ikn});
|
||||||
const analizEksik = analysisStatusBlocksPreparation(effectiveResults.sourceReview?.status);
|
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;
|
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();
|
const session = getToken();
|
||||||
setHata(null);
|
setHata(null);
|
||||||
try {
|
try {
|
||||||
const initialDeadlineProblem=preparationDeadlineProblem(tender);
|
const initialDeadlineProblem=preparationDeadlineProblem(tender,now || new Date());
|
||||||
if(initialDeadlineProblem)throw new Error(initialDeadlineProblem);
|
if(initialDeadlineProblem)throw new Error(initialDeadlineProblem);
|
||||||
if (!hazir) throw new Error('Kalem, miktar, birim, fiyat, firma ve geçerlilik süresindeki eksikleri giderin.');
|
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.
|
// Open during the click; async validation must not lose popup activation.
|
||||||
@@ -206,7 +210,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
|
|||||||
setVault(currentVault);
|
setVault(currentVault);
|
||||||
const fileErrors = preparationBlockers(lotSelection.rows, documents, checks, currentVault);
|
const fileErrors = preparationBlockers(lotSelection.rows, documents, checks, currentVault);
|
||||||
if (fileErrors.length) throw new Error(fileErrors.join(' '));
|
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);
|
if(currentDeadlineProblem)throw new Error(currentDeadlineProblem);
|
||||||
const f = formVerisi();
|
const f = formVerisi();
|
||||||
if (tip === 'excel') {
|
if (tip === 'excel') {
|
||||||
@@ -219,7 +223,7 @@ export const BidPackageBuilder = ({ results, tender: selectedTender, initialFocu
|
|||||||
setVault(finalVault);
|
setVault(finalVault);
|
||||||
const finalErrors=preparationBlockers(lotSelection.rows,documents,checks,finalVault);
|
const finalErrors=preparationBlockers(lotSelection.rows,documents,checks,finalVault);
|
||||||
if(finalErrors.length)throw new Error(finalErrors.join(' '));
|
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);
|
if(finalDeadlineProblem)throw new Error(finalDeadlineProblem);
|
||||||
downloadBidWorkbook(buffer,tender.ikn);
|
downloadBidWorkbook(buffer,tender.ikn);
|
||||||
printWindow.current?.close();
|
printWindow.current?.close();
|
||||||
|
|||||||
@@ -177,6 +177,12 @@ const TenderDocumentSession = ({ tender }) => {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(`/api/documents/url?ikn=${encodeURIComponent(ikn)}`);
|
const r = await fetch(`/api/documents/url?ikn=${encodeURIComponent(ikn)}`);
|
||||||
const d = await r.json();
|
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);
|
if (!d.success) throw new Error(d.error);
|
||||||
setEkapUrl(d.url);
|
setEkapUrl(d.url);
|
||||||
setBilgi({ dokumanSayisi: d.dokumanSayisi, ekapSekme: sekme ? 'acildi' : 'engellendi' });
|
setBilgi({ dokumanSayisi: d.dokumanSayisi, ekapSekme: sekme ? 'acildi' : 'engellendi' });
|
||||||
@@ -326,16 +332,20 @@ const TenderDocumentSession = ({ tender }) => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{ekapUrl && (
|
{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" />
|
<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">
|
<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. </>
|
? <>Tarayıcınız yeni sekmeyi engelledi. </>
|
||||||
: <>EKAP doküman sayfası yeni sekmede açıldı. Açılmadıysa: </>}
|
: <>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">
|
<a href={ekapUrl} target="_blank" rel="noopener noreferrer" className="text-blue-600 font-bold hover:underline">
|
||||||
EKAP doküman sayfasını aç
|
EKAP doküman sayfasını aç
|
||||||
</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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ export const PwaSettings = () => {
|
|||||||
return <PwaPanel key={`${authUser?.email}:${getToken()}`}/>;
|
return <PwaPanel key={`${authUser?.email}:${getToken()}`}/>;
|
||||||
};
|
};
|
||||||
const PwaPanel = () => {
|
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
|
// Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine
|
||||||
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
|
const [onbellekSayisi, setOnbellekSayisi] = useState(null);
|
||||||
const [onbellekAdi, setOnbellekAdi] = useState('');
|
const [onbellekAdi, setOnbellekAdi] = useState('');
|
||||||
@@ -156,6 +156,10 @@ const PwaPanel = () => {
|
|||||||
<span>Önbelleğe alınmış istek:</span>
|
<span>Önbelleğe alınmış istek:</span>
|
||||||
<strong className="text-slate-900">{onbellekSayisi == null ? '—' : `${onbellekSayisi} kayıt`}</strong>
|
<strong className="text-slate-900">{onbellekSayisi == null ? '—' : `${onbellekSayisi} kayıt`}</strong>
|
||||||
</p>
|
</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">
|
<p className="flex justify-between">
|
||||||
<span>Uygulama sürümü önbelleği:</span>
|
<span>Uygulama sürümü önbelleği:</span>
|
||||||
<strong className="text-emerald-700">{onbellekAdi || '—'}</strong>
|
<strong className="text-emerald-700">{onbellekAdi || '—'}</strong>
|
||||||
@@ -169,7 +173,7 @@ const PwaPanel = () => {
|
|||||||
{cacheLoading ? 'Kontrol ediliyor…' : 'Önbellek durumunu kontrol et'}
|
{cacheLoading ? 'Kontrol ediliyor…' : 'Önbellek durumunu kontrol et'}
|
||||||
</button>
|
</button>
|
||||||
{cacheError && <p role="alert" className="text-xs text-red-700">{cacheError}</p>}
|
{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>
|
</div>
|
||||||
|
|
||||||
{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}
|
{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */}
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender, tender
|
|||||||
import { getTenderStates, saveTenderState } from '../services/account';
|
import { getTenderStates, saveTenderState } from '../services/account';
|
||||||
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants';
|
||||||
import { istanbulDateKey } from '../services/tenderCalendar';
|
import { istanbulDateKey } from '../services/tenderCalendar';
|
||||||
|
import { enqueueTenderMutation, offlineMutationCount, replayOfflineMutations } from '../services/offlineMutationQueue.js';
|
||||||
|
|
||||||
const AppContext = createContext();
|
const AppContext = createContext();
|
||||||
|
|
||||||
@@ -211,12 +212,46 @@ export const AppProvider = ({ children }) => {
|
|||||||
const inFlight = useRef(new Set());
|
const inFlight = useRef(new Set());
|
||||||
const [saveError, setSaveError] = useState(null);
|
const [saveError, setSaveError] = useState(null);
|
||||||
const [savingCount, setSavingCount] = useState(0);
|
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 mutateTender = async (tenderId, patch) => {
|
||||||
const t = tenders.find(t => t.id === tenderId);
|
const t = tenders.find(t => t.id === tenderId);
|
||||||
if (!t || !veriSenkron || !authUser) { setSaveError('Hesap verileri yüklenmeden kayıt yapılamaz.'); return false; }
|
if (!t || !veriSenkron || !authUser) { setSaveError('Hesap verileri yüklenmeden kayıt yapılamaz.'); return false; }
|
||||||
const key = tenderStateKey(t);
|
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; }
|
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;
|
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();
|
const session = getToken();
|
||||||
inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null);
|
inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null);
|
||||||
try {
|
try {
|
||||||
@@ -906,7 +941,7 @@ export const AppProvider = ({ children }) => {
|
|||||||
planYenile,
|
planYenile,
|
||||||
kotaUyarisi,
|
kotaUyarisi,
|
||||||
setKotaUyarisi,
|
setKotaUyarisi,
|
||||||
veriSenkron, saveError, savingCount, mutateTender
|
veriSenkron, saveError, savingCount, mutateTender, offlineQueueSize
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
</AppContext.Provider>
|
</AppContext.Provider>
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -4,11 +4,20 @@
|
|||||||
* Dev'de Vite proxy'si /api isteklerini localhost:3001'e yönlendirir.
|
* 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 } = {}) {
|
export async function fetchLiveTenders({ refresh = false } = {}) {
|
||||||
const res = await fetch(`/api/tenders${refresh ? '?refresh=1' : ''}`);
|
const res = await fetch(`/api/tenders${refresh ? '?refresh=1' : ''}`);
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
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}`);
|
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ü');
|
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 res = await fetch(url, {signal:controller.signal});
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
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}`);
|
throw new Error(`Detay API hatası: HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
if (!data.success) throw new Error(data.error || 'Detay alınamadı');
|
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 res = await fetch(`/api/documents/ilan?ikn=${encodeURIComponent(cleanIkn)}`, { signal: controller.signal });
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
if (!res.ok) {
|
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}`);
|
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.');
|
if (!data.success || data.ikn !== cleanIkn) throw new Error('Resmî detay yanıtı aranan İKN ile uyuşmuyor.');
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ try {
|
|||||||
evidence.checks.signInStatus = response?.status();
|
evidence.checks.signInStatus = response?.status();
|
||||||
await page.getByRole('button', { name: 'Test hesabıyla gir' }).click();
|
await page.getByRole('button', { name: 'Test hesabıyla gir' }).click();
|
||||||
await page.waitForLoadState('networkidle');
|
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() })));
|
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.');
|
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();
|
await page.getByRole('button', { name: 'Günlük Bülten', exact: true }).click();
|
||||||
|
|||||||
@@ -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;
|
const query=new URL(req.url,'http://localhost').searchParams;
|
||||||
qaFiles=query.has('file')?[{...qaFile}]:null;
|
qaFiles=query.has('file')?[{...qaFile}]:null;
|
||||||
const selected=query.has('ready')?readyBid(tenders[0],qaFiles?.[0]):tenders[0];
|
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">
|
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 React from '/node_modules/.vite/deps/react.js';
|
||||||
import ReactDOM from '/node_modules/.vite/deps/react-dom_client.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('button',{onClick:()=>control('release')},'Belge yanıtını serbest bırak'),
|
||||||
React.createElement('p',{role:'status'},status),
|
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('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)));
|
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(AppProvider,{},React.createElement(Probe)));
|
||||||
</script></body></html>`;
|
</script></body></html>`;
|
||||||
res.setHeader('Content-Type','text/html; charset=utf-8');res.end(await server.transformIndexHtml('/qa-source-update',html));return;
|
res.setHeader('Content-Type','text/html; charset=utf-8');res.end(await server.transformIndexHtml('/qa-source-update',html));return;
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { test } from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { currentLabAcceptanceCases as cases } from './fixtures/d10-current-lab.mjs';
|
||||||
|
|
||||||
|
test('D10 current laboratory acceptance fixture is complete and explicit', () => {
|
||||||
|
assert.equal(cases.length, 5);
|
||||||
|
assert.equal(new Set(cases.map(({ ikn }) => ikn)).size, 5);
|
||||||
|
assert.equal(new Set(cases.map(({ id }) => id)).size, 5);
|
||||||
|
|
||||||
|
for (const item of cases) {
|
||||||
|
assert.match(item.ikn, /^2026\/\d{7}$/);
|
||||||
|
assert.match(item.id, /^ilan-\d+$/);
|
||||||
|
assert.match(item.date, /^\d{2}\.\d{2}\.2026$/);
|
||||||
|
assert.match(item.time, /^\d{2}:\d{2}$/);
|
||||||
|
assert.ok(item.titleTerm);
|
||||||
|
assert.ok(item.natureTerm);
|
||||||
|
assert.ok(item.deliveryTerm);
|
||||||
|
assert.ok([60, 120].includes(item.validityDays));
|
||||||
|
assert.ok(item.partialOfferTerm);
|
||||||
|
assert.ok(item.requirements.length >= 5);
|
||||||
|
assert.equal(new Set(item.requirements.map(({ key }) => key)).size, item.requirements.length);
|
||||||
|
for (const requirement of item.requirements) {
|
||||||
|
assert.ok(requirement.label);
|
||||||
|
assert.ok(requirement.term);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
Vendored
+117
@@ -0,0 +1,117 @@
|
|||||||
|
const commonRequirements = [
|
||||||
|
{ key: 'offer-letter', label: 'Teklif mektubu', term: 'Teklif mektubu' },
|
||||||
|
{ key: 'authorization', label: 'Teklif vermeye yetki bilgileri', term: 'Teklif vermeye yetkili olunduğunu gösteren bilgi ve belgeler' },
|
||||||
|
{ key: 'temporary-guarantee', label: 'Geçici teminat', term: 'Geçici teminat' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function tender(definition) {
|
||||||
|
return {
|
||||||
|
...definition,
|
||||||
|
requirements: [...commonRequirements, ...definition.requirements],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Current public-announcement acceptance set selected on 15 September 2026.
|
||||||
|
// These records prove announcement requirements only. EKAP's official document
|
||||||
|
// package, company/product evidence and generated outputs are separate D10 gates.
|
||||||
|
export const currentLabAcceptanceCases = [
|
||||||
|
tender({
|
||||||
|
ikn: '2026/1634449',
|
||||||
|
id: 'ilan-2219605',
|
||||||
|
city: 'Rize',
|
||||||
|
date: '05.10.2026',
|
||||||
|
time: '10:30',
|
||||||
|
scenario: '20-kalem-laboratuvar-cihazi',
|
||||||
|
titleTerm: 'Laboratuvar cihazı',
|
||||||
|
natureTerm: '20 KALEM',
|
||||||
|
deliveryTerm: '50 (Elli) takvim günü',
|
||||||
|
validityDays: 60,
|
||||||
|
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||||
|
requirements: [
|
||||||
|
{ key: 'manufacturer-or-authorized-seller', label: 'İmalatçı/yetkili satıcı/yetkili temsilci belgesi', term: 'Yetkili Satıcı Belgesi' },
|
||||||
|
{ key: 'service-qualification', label: 'TSE Hizmet Yeterlilik Belgesi', term: 'TSE Hizmet Yeterlilik Belgesi' },
|
||||||
|
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||||
|
{ key: 'brand-model', label: 'Marka-model beyanı', term: 'MARKA - MODEL BEYANI' },
|
||||||
|
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
tender({
|
||||||
|
ikn: '2026/1676347',
|
||||||
|
id: 'ilan-2218485',
|
||||||
|
city: 'Sivas',
|
||||||
|
date: '01.10.2026',
|
||||||
|
time: '10:30',
|
||||||
|
scenario: 'cihaz-malzeme-yazilim',
|
||||||
|
titleTerm: 'Laboratuvar cihaz, malzeme ve yazılım',
|
||||||
|
natureTerm: '2 Kısım Cihaz, 1 Kısım Yazılım ve 1 Kısım Sarf Malzeme',
|
||||||
|
deliveryTerm: '3. Kısım yazılımın kurulumu 15 takvim günü',
|
||||||
|
validityDays: 60,
|
||||||
|
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||||
|
requirements: [
|
||||||
|
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||||
|
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
tender({
|
||||||
|
ikn: '2026/1667534',
|
||||||
|
id: 'ilan-2217472',
|
||||||
|
city: 'Konya',
|
||||||
|
date: '28.09.2026',
|
||||||
|
time: '10:30',
|
||||||
|
scenario: 'yedek-parca-ve-sarf',
|
||||||
|
titleTerm: 'Laboratuvar cihaz yedek parça ve sarf malzeme',
|
||||||
|
natureTerm: '28 Kalem Laboratuvar Cihaz Yedek Parça ve Tıbbi Sarf Malzemesi',
|
||||||
|
deliveryTerm: '27. ve 28. kalemler için 10 takvim günü',
|
||||||
|
validityDays: 120,
|
||||||
|
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||||
|
requirements: [
|
||||||
|
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||||
|
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||||
|
{ key: 'sample', label: 'Numune', term: 'Numune sunulması istenmektedir' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
tender({
|
||||||
|
ikn: '2026/1620320',
|
||||||
|
id: 'ilan-2214836',
|
||||||
|
city: 'Zonguldak',
|
||||||
|
date: '01.10.2026',
|
||||||
|
time: '10:00',
|
||||||
|
scenario: '220-kalem-laboratuvar-sarfi',
|
||||||
|
titleTerm: 'Laboratuvar sarf malzemeleri',
|
||||||
|
natureTerm: '220 Kalem Laboratuvar Sarf Malzeme',
|
||||||
|
deliveryTerm: '15(on beş) gün içerisinde',
|
||||||
|
validityDays: 120,
|
||||||
|
partialOfferTerm: 'kısmı teklif verilebilir',
|
||||||
|
requirements: [
|
||||||
|
{ key: 'scope-exclusion', label: 'Kapsam Dışı Belgesi', term: 'Kapsam Dışı Belgesi' },
|
||||||
|
{ key: 'sales-center', label: 'Satış Merkezi Yetki Belgesi', term: 'Satış Merkezi Yetki Belgesi' },
|
||||||
|
{ key: 'uts', label: 'TİTUBB veya ÜTS belgesi', term: 'TİTUBB veya ÜTS belgesi' },
|
||||||
|
{ key: 'sample', label: 'Numune', term: 'Numune sunulması istenmektedir' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
tender({
|
||||||
|
ikn: '2026/1636687',
|
||||||
|
id: 'ilan-2211682',
|
||||||
|
city: 'Ankara',
|
||||||
|
date: '28.09.2026',
|
||||||
|
time: '14:00',
|
||||||
|
scenario: 'kit-karsiligi-otoanalizor',
|
||||||
|
titleTerm: 'Kit karşılığı otoanalizör',
|
||||||
|
natureTerm: '24 Aylık Kit ve Kit Karşılığı Alınacak Otoanalizör',
|
||||||
|
deliveryTerm: '15 (Onbeş) takvim günü',
|
||||||
|
validityDays: 120,
|
||||||
|
partialOfferTerm: 'işin tamamı için teklif verilecektir',
|
||||||
|
requirements: [
|
||||||
|
{ key: 'service-training', label: 'Teknik Servis Eğitim Sertifikası', term: 'Teknik Servis Eğitim Sertifikası' },
|
||||||
|
{ key: 'brochure', label: 'Broşür', term: 'Broşür' },
|
||||||
|
{ key: 'user-guide', label: 'Cihaz ve ODS kullanım kılavuzu', term: 'ODS yazılım sistemine ait kullanım klavuzu' },
|
||||||
|
{ key: 'catalog', label: 'Katalog', term: 'Katalog' },
|
||||||
|
{ key: 'specification-response', label: 'Teknik şartnameye cevaplar ve açıklamalar', term: 'Teknik Şartnameye Cevaplar ve Açıklamalar' },
|
||||||
|
{ key: 'sales-center', label: 'Satış Merkezi Yetki Belgesi', term: 'Satış Merkezi Yetki Belgesi' },
|
||||||
|
{ key: 'device-id-cards', label: 'Cihaz ve sistem tanıtım kimlik kartı', term: 'tanıtım kimlik kartı' },
|
||||||
|
{ key: 'production-and-age', label: 'Üretimde olma ve 13 yaş altı kanıtı', term: 'on üç yaşın altında olduğunu belirten belgeler' },
|
||||||
|
{ key: 'offer-authorization-proof', label: 'Teklif yetkisi kanıtı', term: 'Teklif vermeye yetkili olduğuna dair kanıtlayıcı belge' },
|
||||||
|
{ key: 'uts-evidence', label: 'ÜTS beyanı ve kanıtlayıcı belgeler', term: 'ÜRÜN TAKİP SİSTEMİ (ÜTS) BEYANI VE KANITLAYICI BELGELER' },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
];
|
||||||
@@ -20,7 +20,7 @@ const request=async(path,method='GET',body,auth=token)=>{
|
|||||||
before(async()=>{
|
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));
|
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;
|
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));}}
|
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');
|
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.userData.ok,true);
|
||||||
assert.equal(response.data.checks.sharedData.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()=>{
|
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 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,[]);
|
const keys=await request('/api/me/apikeys');assert.equal(keys.status,200);assert.deepEqual(keys.data.anahtarlar,[]);
|
||||||
|
|||||||
+124
-37
@@ -1,67 +1,154 @@
|
|||||||
// Read-only production acceptance probe for the five health/laboratory tenders.
|
// Read-only production acceptance probe for five current laboratory tenders.
|
||||||
// No authentication, writes, payments, messages, or tender submissions.
|
// It records every D10 gate separately and never turns an upstream blocker into
|
||||||
import { writeFile } from 'node:fs/promises';
|
// a positive result. No authentication, writes, payments, messages or bids.
|
||||||
import { healthAcceptanceCases } from './fixtures/health-acceptance.mjs';
|
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 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 evidencePath = process.env.IHALEIST_LIVE_FIVE_EVIDENCE_PATH || 'docs/quality/evidence/qa-live-five-tenders.json';
|
||||||
const timeoutMs = 30_000;
|
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 controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${base}${path}`, { signal: controller.signal, headers: { 'User-Agent': 'ihaleist-live-five-probe/1.0' } });
|
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);
|
const data = await response.json().catch(() => null);
|
||||||
if (!response.ok || !data || data.success !== true) throw new Error(`${path}: HTTP ${response.status}`);
|
return {
|
||||||
return data;
|
path,
|
||||||
} finally { clearTimeout(timer); }
|
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 byIkn = new Map((Array.isArray(list.tenders) ? list.tenders : []).map((tender) => [String(tender.ikn || '').replace(/\s/g, ''), tender]));
|
||||||
const tenders = [];
|
const tenders = [];
|
||||||
for (const fixture of healthAcceptanceCases) {
|
for (const fixture of currentLabAcceptanceCases) {
|
||||||
const ikn = `2026/${fixture.ikn}`;
|
const listed = byIkn.get(fixture.ikn) || null;
|
||||||
const listed = byIkn.get(ikn);
|
const detailProbe = await probeJson(`/api/tenders/${encodeURIComponent(fixture.id)}/detail`);
|
||||||
if (!listed) throw new Error(`${ikn}: live listede bulunamadı`);
|
const detail = detailProbe.data || {};
|
||||||
const detail = await getJson(`/api/tenders/${encodeURIComponent(listed.id)}/detail`);
|
const announcementText = String(detail.aciklama || '');
|
||||||
const document = await getJson(`/api/documents/ilan?ikn=${encodeURIComponent(ikn)}`);
|
const requirementChecks = fixture.requirements.map(({ key, label, term }) => ({
|
||||||
const officialDate = document.sourceDateTime || `${document.tarih || detail.tarih || ''} ${document.saat || detail.saat || ''}`.trim();
|
key,
|
||||||
if (!officialDate || !document.ikn || document.ikn !== ikn) throw new Error(`${ikn}: resmi tarih/İKN doğrulanamadı`);
|
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({
|
tenders.push({
|
||||||
ikn,
|
ikn: fixture.ikn,
|
||||||
scenario: fixture.scenario,
|
scenario: fixture.scenario,
|
||||||
id: listed.id,
|
id: fixture.id,
|
||||||
title: listed.baslik,
|
title: listed?.baslik || detail.title || null,
|
||||||
institution: listed.kurum,
|
institution: listed?.kurum || detail.institution || null,
|
||||||
city: listed.sehir,
|
city: listed?.sehir || detail.city || fixture.city,
|
||||||
sector: listed.sektor,
|
tenderDate: detail.tarih || null,
|
||||||
procedure: listed.usul,
|
tenderTime: detail.saat || null,
|
||||||
announcementDate: listed.ilanTarihi,
|
sourceUrl: listed?.kaynakUrl || null,
|
||||||
tenderDate: document.tarih || detail.tarih || '',
|
publicAnnouncement: {
|
||||||
tenderTime: document.saat || detail.saat || '',
|
endpointStatus: detailProbe.status,
|
||||||
sourceDateTime: officialDate,
|
textChars: announcementText.length,
|
||||||
officialDateSource: 'documents/ilan sourceDateTime',
|
normalizedTextSha256: announcementText
|
||||||
announcementDateConflict: ikn === '2026/1573235' && listed.tarih !== document.tarih
|
? createHash('sha256').update(normalize(announcementText)).digest('hex')
|
||||||
? `ilan listesi ${listed.tarih}; resmi detay ${document.tarih}` : null,
|
: null,
|
||||||
detailStatus: detail.success === true,
|
checks: announcementChecks,
|
||||||
documentProbeStatus: 200,
|
requirementChecks,
|
||||||
|
},
|
||||||
|
officialDocumentProbe: {
|
||||||
|
endpointStatus: documentProbe.status,
|
||||||
|
code: document.code || null,
|
||||||
|
retryable: document.retryable ?? null,
|
||||||
documentCount: Number(document.dokumanSayisi) || 0,
|
documentCount: Number(document.dokumanSayisi) || 0,
|
||||||
announcementCount: Number(document.ilanSayisi) || 0,
|
announcementCount: Number(document.ilanSayisi) || 0,
|
||||||
okasCount: Number(document.okasSayisi) || 0,
|
okasCount: Number(document.okasSayisi) || 0,
|
||||||
documentEndpointSuccess: document.success === true,
|
error: document.error || documentProbe.error,
|
||||||
sourceUrl: listed.kaynakUrl,
|
},
|
||||||
|
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 = {
|
const result = {
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
base,
|
base,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
|
listProbe: { status: listProbe.status, success: listProbe.ok },
|
||||||
listCount: Array.isArray(list.tenders) ? list.tenders.length : 0,
|
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,
|
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`);
|
await writeFile(evidencePath, `${JSON.stringify(result, null, 2)}\n`);
|
||||||
console.log(JSON.stringify({ base, checkedAt: result.checkedAt, tenders: tenders.map(({ ikn, tenderDate, tenderTime, documentCount }) => ({ ikn, tenderDate, tenderTime, documentCount })) }));
|
console.log(JSON.stringify({
|
||||||
|
base,
|
||||||
|
checkedAt: result.checkedAt,
|
||||||
|
selectedCount: result.selectedCount,
|
||||||
|
announcementPassCount: result.announcementPassCount,
|
||||||
|
officialDocumentPackagePassCount: result.officialDocumentPackagePassCount,
|
||||||
|
positiveCount: result.positiveCount,
|
||||||
|
pass: result.pass,
|
||||||
|
blockerCodes: [...new Set(blockers.map(({ code }) => code).filter(Boolean))],
|
||||||
|
}));
|
||||||
|
if (!result.pass) process.exitCode = 2;
|
||||||
|
|||||||
@@ -0,0 +1,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])) }));
|
||||||
@@ -2,6 +2,7 @@
|
|||||||
// provider calls are performed. It intentionally fails when a required
|
// provider calls are performed. It intentionally fails when a required
|
||||||
// deployment contract is missing.
|
// deployment contract is missing.
|
||||||
import { mkdir, writeFile } from 'node:fs/promises';
|
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 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';
|
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 observedHeaders = [...new Set([...requiredHeaders, 'strict-transport-security'])];
|
||||||
const checks = {};
|
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 controller = new AbortController();
|
||||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||||
try {
|
try {
|
||||||
@@ -24,11 +27,14 @@ async function probe(path, successField = null) {
|
|||||||
contentType: response.headers.get('content-type'),
|
contentType: response.headers.get('content-type'),
|
||||||
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
|
headers: Object.fromEntries(observedHeaders.map(name => [name, response.headers.get(name)])),
|
||||||
};
|
};
|
||||||
if (successField) {
|
if (successField || captureText) {
|
||||||
const raw = await response.text();
|
const raw = await response.text();
|
||||||
|
if (captureText) result.text = raw;
|
||||||
|
if (successField) {
|
||||||
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
|
try { result.json = JSON.parse(raw); } catch { result.jsonError = 'JSON yanıtı ayrıştırılamadı.'; }
|
||||||
result.apiSuccess = result.json?.[successField] === true;
|
result.apiSuccess = result.json?.[successField] === true;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
|
return { error: error.name === 'AbortError' ? `timeout after ${timeoutMs}ms` : error.message };
|
||||||
@@ -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');
|
checks.health = await probe('/api/health', 'ok');
|
||||||
// /api/health and /api/ready use the same { ok: true } probe contract. The
|
// /api/health and /api/ready use the same { ok: true } probe contract. The
|
||||||
// readiness payload also includes a separate `ready` diagnostic field.
|
// readiness payload also includes a separate `ready` diagnostic field.
|
||||||
checks.ready = await probe('/api/ready', 'ok');
|
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;
|
// The EKAP document fallback depends on the bundled browser being available;
|
||||||
// check its public capability endpoint without opening a document or mutating
|
// check its public capability endpoint without opening a document or mutating
|
||||||
// any account data.
|
// any account data.
|
||||||
checks.documents = await probe('/api/documents/config', 'tarayiciVar');
|
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 = {
|
const report = {
|
||||||
base,
|
base,
|
||||||
readOnly: true,
|
readOnly: true,
|
||||||
checkedAt: new Date().toISOString(),
|
checkedAt: new Date().toISOString(),
|
||||||
|
expectedRelease: expectedRelease || null,
|
||||||
|
expectedAssetSha256: expectedAssetSha256 || null,
|
||||||
checks,
|
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,
|
missingHeaders,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;/);
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -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/);
|
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});
|
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/);
|
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)=>{
|
globalThis.fetch=async(url,{signal})=>new Promise((resolve,reject)=>{
|
||||||
if(signal.aborted) reject(new DOMException('Cancelled','AbortError'));
|
if(signal.aborted) reject(new DOMException('Cancelled','AbortError'));
|
||||||
else signal.addEventListener('abort',()=>reject(new DOMException('Cancelled','AbortError')),{once:true});
|
else signal.addEventListener('abort',()=>reject(new DOMException('Cancelled','AbortError')),{once:true});
|
||||||
|
|||||||
Reference in New Issue
Block a user