diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index 3db06a5..c888435 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -206,3 +206,11 @@ SQLite snapshot, referanslı belge kopyası, SHA-256 manifesti, bütünlük kont - Fiziksel silme sırasında yalnız ENOENT (zaten yok) tolere ediliyor. Erişim/tür/disk hataları artık bastırılmıyor; belge metadata kaydı silinmeden hata dönüyor. - Kanıt: 57/57 test geçti. SQLite trigger ile gerçek INSERT hatası üretildi; dosya listesi değişmedi ve önceden kayıtlı Türkçe belge baytları korundu. Dizin üzerinde unlink hatasıyla metadata koruması ve diğer hesabın silme reddi doğrulandı. Mevcut HTTP yükleme/indirme testi geçti. - Kalan sınır: süreç aniden kapanırken dosya/DB arasında tam atomiklik ve fiziksel silme sonrasındaki DB silme hatasının kurtarılması henüz çözülmüş değildir. Bunlar dosya yaşam döngüsü kapsamında açık tutuluyor; tam belge dayanıklılığı kabulü verilmedi. + +### D09 — PWA önbelleği ve gerçek durum açıklaması + +- PWA ekranındaki çalışmayan aç/kapat benzeri düğme kaldırıldı; düğme gerçekten Cache API üzerinden İhaleist önbellek durumunu yeniden okuyor. Destek/hata/bekleme durumları gösteriliyor. Koşulsuz “Senkronize” yerine bağlantı durumu var; bağlantının sunucuya erişim garantisi olmadığı belirtiliyor. +- Ürün metni mevcut davranışla hizalandı: uygulama kabuğu açılabilir, özel belge/hazırlık kaydı için bağlantı gerekir; çevrimdışı yazma kuyruğu varmış gibi sunulmuyor. +- Service worker v3 yalnız bilinen kabuk ve varlık yollarını önbellekliyor. API, authorization başlıklı istekler ve rastgele GET yolları ağda kalıyor. Başarısız/yanlış MIME yanıtları sağlam kabuğu veya JS/CSS’yi bozamıyor; HTML yedeği varlık isteğine dönmüyor. Aktivasyon sadece eski İhaleist cache adlarını temizliyor. +- Kanıt: gerçek sw.js kodunu VM içinde fetch/activate olaylarıyla çalıştıran iki regresyon eklendi; 59/59 test ve üretim derlemesi geçti. API/özel yol, authorization, sunucu 500, offline kabuk, offline asset, yanlış MIME ve başka uygulama cache’ini koruma kontrol edildi. +- Sınır: VM testleri gerçek service worker kurulumu/güncellemesi veya cihazda offline kullanım kabulü değildir. Bunlar ve hesap değişiminin gerçek tarayıcıdaki tüm PWA senaryoları hâlâ açık. diff --git a/public/sw.js b/public/sw.js index 17e5f6a..35671e0 100644 --- a/public/sw.js +++ b/public/sw.js @@ -1,19 +1,19 @@ /* İhaleist service worker — uygulama kabuğu çevrimdışı açılır, API her zaman ağdan. Sürüm değişince eski önbellek temizlenir (build her deploy'da yeni asset adı üretir). */ -const SURUM = 'ihaleist-v2'; // v2: yanlış MIME ile saklanmış pdf worker kopyaları temizlensin +const SURUM = 'ihaleist-v3'; // v3: yalnız uygulama kabuğu ve başarılı varlık yanıtları const KABUK = ['/', '/manifest.webmanifest', '/icons/icon-192.png', '/icons/icon-512.png']; self.addEventListener('install', (e) => { e.waitUntil(caches.open(SURUM).then((c) => c.addAll(KABUK)).then(() => self.skipWaiting())); }); self.addEventListener('activate', (e) => { - e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k !== SURUM).map((k) => caches.delete(k)))).then(() => self.clients.claim())); + e.waitUntil(caches.keys().then((ks) => Promise.all(ks.filter((k) => k.startsWith('ihaleist-') && k !== SURUM).map((k) => caches.delete(k)))).then(() => self.clients.claim())); }); self.addEventListener('fetch', (e) => { const url = new URL(e.request.url); if (e.request.method !== 'GET' || url.origin !== self.location.origin) return; // API: yalnız ağ — önbellekten eski ihale gösterme - if (url.pathname.startsWith('/api/')) return; + if (url.pathname.startsWith('/api/') || e.request.headers.has('authorization')) return; // Hash'li build varlıkları: cache-first (değişmez adlar) if (url.pathname.startsWith('/assets/')) { e.respondWith(caches.match(e.request).then((hit) => hit || fetch(e.request).then((r) => { @@ -22,15 +22,23 @@ self.addEventListener('fetch', (e) => { // sunucu düzeltilse bile kullanıcı eski bozuk kopyayı görmeye devam eder. const tip = r.headers.get('content-type') || ''; const jsMi = /\.m?js$/.test(url.pathname); - if (r.ok && (!jsMi || /javascript|ecmascript/i.test(tip))) { - const kopya = r.clone(); caches.open(SURUM).then((c) => c.put(e.request, kopya)); + if (r.ok && !r.redirected && !/text\/html/i.test(tip) && (!jsMi || /javascript|ecmascript/i.test(tip)) && (!/\.css$/.test(url.pathname) || /text\/css/i.test(tip))) { + const kopya = r.clone(); e.waitUntil(caches.open(SURUM).then((c) => c.put(e.request, kopya))); } return r; }))); return; } + // Only known shell files and root navigation are cacheable; never cache arbitrary GETs. + const navigation = e.request.mode === 'navigate' && url.pathname === '/'; + if (!navigation && !KABUK.includes(url.pathname)) return; + if (e.request.headers.has('authorization')) return; // Kabuk / sayfa: network-first, ağ yoksa önbellekten index e.respondWith(fetch(e.request).then((r) => { - const kopya = r.clone(); caches.open(SURUM).then((c) => c.put(e.request, kopya)); return r; - }).catch(() => caches.match(e.request).then((hit) => hit || caches.match('/')))); + const type = r.headers.get('content-type') || ''; + if (r.ok && !r.redirected && (!navigation || /text\/html/i.test(type))) { + const kopya = r.clone(); e.waitUntil(caches.open(SURUM).then(c => c.put(navigation ? '/' : e.request, kopya))); + } + return r; + }).catch(() => caches.match(e.request).then((hit) => hit || (navigation ? caches.match('/') : Response.error())))); }); diff --git a/src/components/tools/PwaSettings.jsx b/src/components/tools/PwaSettings.jsx index da7750d..66841e0 100644 --- a/src/components/tools/PwaSettings.jsx +++ b/src/components/tools/PwaSettings.jsx @@ -10,18 +10,26 @@ export const PwaSettings = () => { // Gerçek Cache API sayımı — sahte "102 ihale önbellekte" yerine const [onbellekSayisi, setOnbellekSayisi] = useState(null); const [onbellekAdi, setOnbellekAdi] = useState(''); + const [cacheLoading, setCacheLoading] = useState(false); + const [cacheError, setCacheError] = useState(null); + const [online, setOnline] = useState(navigator.onLine); + const refreshCacheStatus = async () => { + setCacheLoading(true); setCacheError(null); + try { + if (!('caches' in window)) throw new Error('Bu tarayıcı uygulama önbelleğini desteklemiyor.'); + const adlar = (await caches.keys()).filter(name => name.startsWith('ihaleist-')); + let toplam = 0; + for (const ad of adlar) toplam += (await (await caches.open(ad)).keys()).length; + setOnbellekSayisi(toplam); setOnbellekAdi(adlar.join(', ')); + } catch (error) { setCacheError(error.message); setOnbellekSayisi(null); } + finally { setCacheLoading(false); } + }; + useEffect(() => { refreshCacheStatus(); }, []); useEffect(() => { - if (!('caches' in window)) return; - (async () => { - try { - const adlar = await caches.keys(); - let toplam = 0; - for (const ad of adlar) toplam += (await (await caches.open(ad)).keys()).length; - setOnbellekSayisi(toplam); setOnbellekAdi(adlar[adlar.length - 1] || ''); - } catch { setOnbellekSayisi(null); } - })(); + const changed = () => setOnline(navigator.onLine); + window.addEventListener('online', changed); window.addEventListener('offline', changed); + return () => { window.removeEventListener('online', changed); window.removeEventListener('offline', changed); }; }, []); - const [offlineMode, setOfflineMode] = useState(true); const [installed, setInstalled] = useState(false); const [permission, setPermission] = useState(typeof Notification !== 'undefined' ? Notification.permission : 'unsupported'); @@ -105,8 +113,8 @@ export const PwaSettings = () => {
-

Mobil PWA & Saha Ekibi Offline Modu

-

Saha satış temsilcileriniz için internet bağlantısı olmadığında dahi çevrimdışı çalışma ve anlık push bildirimi.

+

Mobil uygulama ve bağlantı durumu

+

Uygulama kabuğu önbellekten açılabilir. İhale güncelleme, belge erişimi ve hazırlık kaydı için internet bağlantısı gerekir.

@@ -118,10 +126,10 @@ export const PwaSettings = () => {

- {offlineMode ? : } Çevrimdışı (Offline) Önbellek + {online ? : } Çevrimdışı (Offline) Önbellek

- Senkronize + {online ? 'Bağlantı var' : 'Çevrimdışı'}
@@ -141,11 +149,13 @@ export const PwaSettings = () => {
+ {cacheError &&

{cacheError}

} +

Bağlantı durumu sunucuya erişimi garanti etmez. Çevrimdışı değişiklikler otomatik gönderim kuyruğuna alınmaz; kayıt sonucunu kontrol edin.

{/* Gerçek Tarayıcı Bildirimleri + Sessiz Saatler */} diff --git a/tests/service-worker.test.mjs b/tests/service-worker.test.mjs new file mode 100644 index 0000000..55b7b99 --- /dev/null +++ b/tests/service-worker.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import vm from 'node:vm'; +import fs from 'node:fs'; +const script=fs.readFileSync(new URL('../public/sw.js',import.meta.url),'utf8'); +function harness() { + const handlers={},writes=[],deleted=[]; + let network=()=>Promise.resolve(new Response('app',{headers:{'content-type':'text/html'}})); + const cached=new Map([['/',new Response('offline app')]]); + const key=r=>typeof r==='string'?r:new URL(r.url).pathname; + const caches={keys:async()=>['ihaleist-v2','ihaleist-v3','other-app'], + delete:async name=>{deleted.push(name);return true;}, + match:async request=>cached.get(key(request))?.clone(), + open:async()=>({put:async(request,response)=>{writes.push(key(request));cached.set(key(request),response);},addAll:async()=>{}})}; + vm.runInNewContext(script,{URL,Response,caches,fetch:request=>network(request),self:{location:{origin:'https://ihaleist.test'}, + addEventListener:(name,handler)=>{handlers[name]=handler;},clients:{claim:async()=>{}},skipWaiting:async()=>{}}}); + return {writes,deleted,handlers,setNetwork:fn=>{network=fn;},async fetch(path,{mode='cors',authorization=false,method='GET'}={}) { + let response;const pending=[]; + handlers.fetch({request:{url:'https://ihaleist.test'+path,method,mode,headers:new Headers(authorization?{authorization:'Bearer test'}:{})}, + respondWith:p=>{response=p;},waitUntil:p=>pending.push(p)}); + const out=await response;await Promise.all(pending);return out; + }}; +} +test('worker ignores API, arbitrary GET and authorized resources; removes only own old caches',async()=>{ + const h=harness(); + for(const path of ['/api/me/documents','/mcp','/private-report'])assert.equal(await h.fetch(path),undefined); + assert.equal(await h.fetch('/assets/app.js',{authorization:true}),undefined); + assert.equal(await h.fetch('/',{method:'POST'}),undefined); + assert.deepEqual(h.writes,[]); + let activation;h.handlers.activate({waitUntil:p=>{activation=p;}});await activation; + assert.deepEqual(h.deleted,['ihaleist-v2']); +}); +test('failed navigation never overwrites offline shell, assets never receive HTML fallback',async()=>{ + const h=harness(); + h.setNetwork(async()=>new Response('server error',{status:500})); + assert.equal((await h.fetch('/',{mode:'navigate'})).status,500); + assert.deepEqual(h.writes,[]); + h.setNetwork(async()=>{throw new Error('offline');}); + assert.equal(await (await h.fetch('/',{mode:'navigate'})).text(),'offline app'); + await assert.rejects(()=>h.fetch('/assets/app.js'),/offline/); + h.setNetwork(async()=>new Response('wrong asset',{headers:{'content-type':'text/html'}})); + await h.fetch('/assets/app.js');await h.fetch('/assets/app.css');assert.deepEqual(h.writes,[]); + h.setNetwork(async()=>new Response('export default 1',{headers:{'content-type':'text/javascript'}})); + await h.fetch('/assets/app.js');assert.deepEqual(h.writes,['/assets/app.js']); +});