diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 639472f..ff38435 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -241,3 +241,10 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont - Başlık/kurum/İKN/kalem araması ortak normalizasyon kullanıyor: Türkçe büyük-küçük harf, birleşik Unicode karakterleri, noktasız ı ve fazla boşluklar aynı şekilde ele alınıyor. Metin ve nesne biçimindeki kalem özetleri destekleniyor. - Tam kalem havuzu eşleşmeleri artık üretildikleri sorgu ile saklanıyor. Kullanıcı yeni sorguya geçtiği render anında önceki sorgunun eşleşme haritası kullanılmıyor; debounce/ağ beklerken eski sonuçlar yeni aramaya karışmıyor. Mevcut geç yanıt iptal kontrolü korunuyor. - Kanıt: Türkçe alan eşleşmeleri ve hemogram → mikroskop sorgu geçişi regresyonlarıyla 65/65 test ve build geçti. Gerçek arama sağlayıcısının tüm eşanlamlı/tıbbi ürün kapsamı ve tarayıcıda geciktirilmiş ağ kabulü henüz doğrulanmadı. + +### D01 / D05 — Detay yükleme hatası ve yeniden deneme + +- İlan detayını yüklerken boş kaynak metni artık sessiz başarı/boş ekran olarak bırakılmıyor. Yükleniyor, hata ve yeniden deneme durumları detay penceresinde gösteriliyor. +- Detay isteğine 30 saniye zaman aşımı ve iptal sinyali eklendi; pencere kapatıldığında/ihale değiştiğinde istek durduruluyor ve geç yanıt önceki kayda uygulanmıyor. +- Kaynak detayının kullanıcıya özel görev/not/taslak alanlarını ezmemesi için yanıt publicTender süzgecinden geçiriliyor. +- Kanıt: boş başarılı yanıt, sonraki başarılı deneme, HTTP 503, zaman aşımı ve iptal regresyonuyla 66/66 test ve build geçti. Gerçek kaynakta hata→yeniden deneme tarayıcı kabulü ve tüm kaynak türlerinin detay akışları ayrıca açık. diff --git a/src/components/TenderDetailModal.jsx b/src/components/TenderDetailModal.jsx index 25ea01a..1d938ed 100644 --- a/src/components/TenderDetailModal.jsx +++ b/src/components/TenderDetailModal.jsx @@ -13,7 +13,7 @@ import { tenderStateKey } from '../services/tenderState'; import { createTenderICS } from '../services/tenderCalendar'; export const TenderDetailModal = () => { - const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults, tenders, mutateTender, savingCount, saveError, authUser } = useApp(); + const { activeTenderDetail, setActiveTenderDetail, toggleTrackTender, userProfile, setIsAiSpecAnalyzerOpen, gorevAc, gorevKontrol, aiAnalysisResults, tenders, mutateTender, savingCount, saveError, authUser, detailLoad, retryTenderDetail } = useApp(); const dialogRef = useRef(null); const dialogTitleId = useId(); const isOpen = Boolean(activeTenderDetail); @@ -105,6 +105,11 @@ export const TenderDetailModal = () => {
{(actionError || saveError) &&

{actionError || saveError}

} + {detailLoad?.id === tender.id && detailLoad.status === 'loading' &&

Kaynak ilan metni yükleniyor…

} + {detailLoad?.id === tender.id && detailLoad.status === 'error' &&
+

{detailLoad.error}

+ +
} {savingCount > 0 &&

Kaydediliyor…

} {/* Top Header Controls */}
diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 4da7367..696da2e 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -281,26 +281,33 @@ export const AppProvider = ({ children }) => { }, []); const [activeTenderDetail, setActiveTenderDetail] = useState(null); + const [detailLoad, setDetailLoad] = useState(null); + const [detailRetry, setDetailRetry] = useState(0); + const retryTenderDetail = () => setDetailRetry(value => value + 1); // Canlı ihalede detay açıldığında ilan metnini API'den tamamla useEffect(() => { const t = activeTenderDetail; - if (!t || !t.id?.startsWith('ilan-') || t.aciklama) return; + if (!t || !t.id?.startsWith('ilan-') || t.aciklama) { setDetailLoad(null); return; } let cancelled = false; - fetchTenderDetail(t.id) + const controller = new AbortController(); + setDetailLoad({id:t.id,status:'loading'}); + fetchTenderDetail(t.id, {signal:controller.signal}) .then(detail => { - if (cancelled || !detail.aciklama) return; + if (cancelled) return; + const source = publicTender(detail); + setDetailLoad({id:t.id,status:'loaded'}); setActiveTenderDetail(prev => - prev && prev.id === t.id ? { ...prev, ...detail } : prev + prev && prev.id === t.id ? { ...prev, ...source } : prev ); setTenders(prev => - prev.map(x => (x.id === t.id ? { ...x, ...detail } : x)) + prev.map(x => (x.id === t.id ? { ...x, ...source } : x)) ); }) - .catch(() => {}); - return () => { cancelled = true; }; + .catch(error => { if (!cancelled) setDetailLoad({id:t.id,status:'error',error:error.message}); }); + return () => { cancelled = true; controller.abort(); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [activeTenderDetail?.id]); + }, [activeTenderDetail?.id, detailRetry]); const [isDetailedSearchOpen, setIsDetailedSearchOpen] = useState(false); const [isCompanyScannerOpen, setIsCompanyScannerOpen] = useState(false); const [isAiSpecAnalyzerOpen, setIsAiSpecAnalyzerOpen] = useState(false); @@ -737,6 +744,8 @@ export const AppProvider = ({ children }) => { addSavedFilter, deleteSavedFilter, activeTenderDetail, + detailLoad, + retryTenderDetail, setActiveTenderDetail, isDetailedSearchOpen, setIsDetailedSearchOpen, diff --git a/src/services/tenderApi.js b/src/services/tenderApi.js index a0fca6d..f1b1e91 100644 --- a/src/services/tenderApi.js +++ b/src/services/tenderApi.js @@ -12,12 +12,27 @@ export async function fetchLiveTenders({ refresh = false } = {}) { return data; } -export async function fetchTenderDetail(tenderId) { - const res = await fetch(`/api/tenders/${tenderId}/detail`); - if (!res.ok) throw new Error(`Detay API hatası: HTTP ${res.status}`); - const data = await res.json(); - if (!data.success) throw new Error(data.error || 'Detay alınamadı'); - return data; +export async function fetchTenderDetail(tenderId, {signal, timeoutMs = 30000} = {}) { + const controller = new AbortController(); + const cancel = () => controller.abort(); + if (signal?.aborted) cancel(); + else signal?.addEventListener('abort',cancel,{once:true}); + let timedOut = false; + const timer = setTimeout(()=>{timedOut=true;controller.abort();},timeoutMs); + try { + const res = await fetch(`/api/tenders/${encodeURIComponent(tenderId)}/detail`, {signal:controller.signal}); + if (!res.ok) throw new Error(`Detay API hatası: HTTP ${res.status}`); + const data = await res.json(); + if (!data.success) throw new Error(data.error || 'Detay alınamadı'); + if (typeof data.aciklama !== 'string' || !data.aciklama.trim()) + throw new Error('Kaynak ilan metni alınamadı. Yeniden deneyin veya asıl ilanı kontrol edin.'); + return data; + } catch (error) { + if (timedOut) throw new Error('İlan metni yüklenirken zaman aşımı oluştu. Yeniden deneyin.'); + throw error; + } finally { + clearTimeout(timer); signal?.removeEventListener('abort',cancel); + } } // Kullanıcının ihale üzerindeki durumunu (kanban, takip vb.) canlı veriyle birleştirir. diff --git a/tests/tender-detail-fetch.test.mjs b/tests/tender-detail-fetch.test.mjs new file mode 100644 index 0000000..74b40eb --- /dev/null +++ b/tests/tender-detail-fetch.test.mjs @@ -0,0 +1,22 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {fetchTenderDetail} from '../src/services/tenderApi.js'; +test('detail loading rejects empty source, supports retry and stops on timeout/cancel',async()=>{ + const original=globalThis.fetch; + try { + globalThis.fetch=async()=>new Response(JSON.stringify({success:true,aciklama:''})); + await assert.rejects(()=>fetchTenderDetail('ilan-1'),/Kaynak ilan metni alınamadı/); + globalThis.fetch=async()=>new Response(JSON.stringify({success:true,aciklama:'Tam ilan metni'})); + assert.equal((await fetchTenderDetail('ilan-1')).aciklama,'Tam ilan metni'); + globalThis.fetch=async()=>new Response('error',{status:503}); + await assert.rejects(()=>fetchTenderDetail('ilan-1'),/HTTP 503/); + globalThis.fetch=async(url,{signal})=>new Promise((resolve,reject)=>{ + if(signal.aborted) reject(new DOMException('Cancelled','AbortError')); + else signal.addEventListener('abort',()=>reject(new DOMException('Cancelled','AbortError')),{once:true}); + }); + await assert.rejects(()=>fetchTenderDetail('ilan-1',{timeoutMs:5}),/zaman aşımı/); + const controller=new AbortController(); + const pending=fetchTenderDetail('ilan-1',{signal:controller.signal});controller.abort(); + await assert.rejects(()=>pending,{name:'AbortError'}); + } finally { globalThis.fetch=original; } +});