diff --git a/docs/quality/uygulama-durumu.md b/docs/quality/uygulama-durumu.md index e716ab9..b5dea57 100644 --- a/docs/quality/uygulama-durumu.md +++ b/docs/quality/uygulama-durumu.md @@ -49,9 +49,16 @@ Tarayıcı kanıtı: [Yenileme sonrası beş kart](evidence/qa-kanban-after-relo - Derin rapor ve hazırlık formu, açılışta seçilmiş eski nesne yerine ihale havuzundaki güncel kullanıcı kaydını okur. Böylece AI penceresinden tekrar açılan formda yeni kaydedilmiş taslağa erişilir. - HTTP testinde yol haritası kaynak/durum alanlarının yazma-okuma kontrolü eklendi. Arayüzde iki pencere arasında geçiş ve yeniden giriş senaryosu hâlâ ayrıca doğrulanmalıdır. +## Hesap ve oturum ayrımı + +- Firma profili, ekip, favoriler ve filtreler ortak localStorage kayıtlarından başlatılmıyor; yeni hesaba boş varsayılanlar ve ardından sunucudaki kendi kaydı yükleniyor. Önceki hesaba ait eksik alanlar yeni profille birleştirilmiyor. +- Ortak ihale önbelleği yalnız kamuya açık ihale alanlarını tutar; not, checklist, takip ve teklif taslağı çıkarılır. Eski önbellek okunurken de bu alanlar temizlenir. Eski yerel kayıtlar otomatik başka hesaba aktarılmaz. +- Oturum geri yükleme, kayıt/hata, plan, analiz, firma taraması ve bildirim yanıtları oturum değişmişse yeni hesaba uygulanmaz. Başka sekmedeki oturum değişikliği dinlenir. Ziyaret ve belge uyarısı anahtarları hesaba ayrılır. +- İki regresyon testi eski oturum yanıtının yeni hesabı silememesini/değiştirememesini ve ortak ihale önbelleğinin özel hazırlık durumunu taşımamasını doğrular. Tarayıcıda iki gerçek test hesabı ve offline senaryosu ayrıca kabul edilmeli. Bu değişiklik, özel profil verisinin offline erişimini sunucu senkronu tamamlanana kadar sınırlar. + ## Doğrulama -`npm test`: 25/25 geçti (son yeniden çalıştırma: 6 Eylül 2026). +`npm test`: 27/27 geçti (son yeniden çalıştırma: 6 Eylül 2026). `npm run build`: geçti; mevcut büyük paket/dinamik import uyarıları sürüyor. `node --check server/index.js`, `node --check server/user-store.mjs`, `git diff --check`: geçti. diff --git a/src/context/AppContext.jsx b/src/context/AppContext.jsx index 4c5a486..eee91dd 100644 --- a/src/context/AppContext.jsx +++ b/src/context/AppContext.jsx @@ -5,7 +5,7 @@ import { getToken } from '../services/auth'; import { fetchLiveTenders, fetchTenderDetail, mergeUserState } from '../services/tenderApi'; import { restoreSession, clearSession, getStoredUser } from '../services/auth'; import { getAllUserData, setUserData, getPlan, bildirimleriGetir, bildirimOkundu, listDocuments } from '../services/account'; -import { applyTenderStates, tenderStateKey, tenderSnapshot } from '../services/tenderState'; +import { applyTenderStates, tenderStateKey, tenderSnapshot, publicTender } from '../services/tenderState'; import { getTenderStates, saveTenderState } from '../services/account'; import { SECTORS_LIST, INSTITUTION_TYPES_LIST } from '../data/constants'; @@ -25,16 +25,13 @@ const yerelKaydet = (anahtar, deger) => { return false; } }; -const yerelOku = (anahtar, varsayilan) => { - try { - const ham = localStorage.getItem(anahtar); - if (!ham) return varsayilan; - const v = JSON.parse(ham); - return v == null ? varsayilan : v; - } catch { - return varsayilan; - } -}; +const defaultReminder = () => ({emailEnabled:true,smsEnabled:true,pushEnabled:true, + notify3DaysBefore:true,notify1DayBefore:true,notify3HoursBefore:true, + googleCalendarSync:true,outlookSync:false,quietEnabled:false,quietStart:'22:00',quietEnd:'08:00'}); +const defaultProfile = user => ({name:user?.name || user?.email || 'Kullanıcı', + username:(user?.email || '').split('@')[0] || 'kullanici',company:'',website:'',trialDaysLeft:7, + role:'Yönetici Tedarikçi',uyumEsigi:60,scrapedCapabilities:{scrapedAt:null,extractedSectors:[], + extractedProducts:[],extractedCertifications:[],ihaleKonulari:[],matchScoreBoost:0}}); export const AppProvider = ({ children }) => { const [activeTab, setActiveTab] = useState('list'); // 'list', 'bulten', 'sonuc', 'takvim', 'sozlesme', 'kdv', 'fiyat', 'kanban', 'smart_filter_builder' @@ -44,7 +41,7 @@ export const AppProvider = ({ children }) => { try { const saved = localStorage.getItem(TENDER_CACHE_KEY); const parsed = saved ? JSON.parse(saved) : null; - return Array.isArray(parsed) ? parsed.map(t => ({...t,takipte:false,gorev:null,notes:[],checklist:[],incelendi:false,atananPersonel:null,kanbanStatus:'incelenecek',bidDraft:null})) : []; + return Array.isArray(parsed) ? parsed.map(publicTender) : []; } catch { try { localStorage.removeItem(TENDER_CACHE_KEY); } catch { /* yok say */ } return []; @@ -107,10 +104,7 @@ export const AppProvider = ({ children }) => { const [onlyValidOnly, setOnlyValidOnly] = useState(true); const [quickFilter, setQuickFilter] = useState('all'); - const [savedFilters, setSavedFilters] = useState(() => { - const v = yerelOku('ihaleist_saved_filters', []); - return Array.isArray(v) ? v : []; - }); + const [savedFilters, setSavedFilters] = useState([]); const [isLiveMode, setIsLiveMode] = useState(true); const [isRefreshingLive, setIsRefreshingLive] = useState(false); @@ -129,6 +123,12 @@ export const AppProvider = ({ children }) => { .finally(() => setAuthReady(true)); }, []); + useEffect(() => { + const sync = event => { if (event.key === 'ihaleist_user' || event.key === 'ihaleist_token') setAuthUser(getStoredUser()); }; + window.addEventListener('storage', sync); + return () => window.removeEventListener('storage', sync); + }, []); + // "Son girişten beri" kartı için önceki ziyaretin zamanı. Ziyaret = tarayıcı // sekmesi oturumu (sessionStorage): sayfa yenilemek sayacı sıfırlamaz, yeni // sekme/gün açmak önceki damgayı "önceki giriş" yapar. Eskiden sabit 14'tü. @@ -136,42 +136,48 @@ export const AppProvider = ({ children }) => { useEffect(() => { if (!authUser) return; try { - const anahtar = 'ihaleist_son_giris'; + const anahtar = 'ihaleist_son_giris:' + authUser.email; const onceki = Number(localStorage.getItem(anahtar)) || null; - const oturum = sessionStorage.getItem('ihaleist_oturum_giris'); + const oturum = sessionStorage.getItem('ihaleist_oturum_giris:' + authUser.email); if (oturum !== null) { // aynı sekmede yenileme: damga sabit kalır setOncekiGiris(Number(oturum) || null); return; } setOncekiGiris(onceki); - sessionStorage.setItem('ihaleist_oturum_giris', String(onceki || 0)); + sessionStorage.setItem('ihaleist_oturum_giris:' + authUser.email, String(onceki || 0)); localStorage.setItem(anahtar, String(Date.now())); } catch { /* depolama kapalıysa kart bugünü gösterir */ } }, [authUser?.email]); // Giriş yapınca kullanıcı verisini SUNUCUDAN yükle (localStorage'ı ezer) useEffect(() => { - if (!authUser) { setVeriSenkron(false); setPlan(null); return; } + setVeriSenkron(false); setPlan(null); setSaveError(null); + setFavoriteKurumlar([]); setTeamMembers([]); setSavedFilters([]); setCompetitorWatchlist([]); + setReminderSettings(defaultReminder()); setUserProfile(defaultProfile(authUser)); + setVaultDocuments([]); setActiveTenderDetail(null); setAiAnalysisResults(null); setNotifications([]); setOncekiGiris(null); + setTenders(prev => prev.map(publicTender)); + if (!authUser) return; planYenile(); let cancelled = false; setVeriSenkron(false); ilkYazmaAtlanan.current.clear(); savedTenderStates.current = {}; + const loadSession = getToken(); Promise.all([getAllUserData(), getTenderStates()]) .then(([v, records]) => { - if (cancelled) return; + if (cancelled || getToken() !== loadSession) return; setFavoriteKurumlar(v.favori_kurumlar || []); setTeamMembers(v.ekip || []); setSavedFilters(v.saved_filters || []); - if (v.reminder) setReminderSettings((s) => ({ ...s, ...v.reminder })); + setReminderSettings({...defaultReminder(), ...v.reminder}); setCompetitorWatchlist(v.rakip_izleme || []); - if (v.firma_profil) setUserProfile(p => ({...p,...v.firma_profil})); + setUserProfile({...defaultProfile(authUser), ...v.firma_profil}); savedTenderStates.current = records; - setTenders(prev => applyTenderStates(prev, records)); + setTenders(prev => applyTenderStates(prev.map(publicTender), records)); setVeriSenkron(true); setSaveError(null); }) - .catch((e) => { if (!cancelled) setSaveError('Hesap verileri yüklenemedi: ' + e.message); }); + .catch((e) => { if (!cancelled && getToken() === loadSession) setSaveError('Hesap verileri yüklenemedi: ' + e.message); }); return () => { cancelled = true; }; // eslint-disable-next-line react-hooks/exhaustive-deps }, [authUser?.email]); @@ -190,21 +196,23 @@ export const AppProvider = ({ children }) => { const key = tenderStateKey(t); if (inFlight.current.has(key)) return false; const owner = authUser.email; + const session = getToken(); inFlight.current.add(key); setSavingCount(n => n + 1); setSaveError(null); try { const record = await saveTenderState(key, patch, tenderSnapshot(t), savedTenderStates.current[key]?.version || 0); - if (getStoredUser()?.email !== owner) return false; + if (getStoredUser()?.email !== owner || getToken() !== session) return false; savedTenderStates.current = {...savedTenderStates.current,[key]:record}; setTenders(prev => applyTenderStates(prev, savedTenderStates.current)); return true; } catch (e) { + if (getStoredUser()?.email !== owner || getToken() !== session) return false; setSaveError(e.message); if (e.kod === 'VERSION_CONFLICT') { try { const records = await getTenderStates(); - if (getStoredUser()?.email === owner) { + if (getStoredUser()?.email === owner && getToken() === session) { savedTenderStates.current = records; - setTenders(prev => applyTenderStates(prev, records)); + setTenders(prev => applyTenderStates(prev.map(publicTender), records)); } } catch { /* retain visible error */ } } @@ -223,15 +231,17 @@ export const AppProvider = ({ children }) => { const sunucuyaYaz = (anahtar, deger) => { - if (!authUser) return; + if (!authUser || !veriSenkron || getStoredUser()?.email !== authUser.email) return; + const session = getToken(); setUserData(anahtar, deger).catch((e) => { + if (getToken() !== session) return; if (e.kod === 'KOTA_ASILDI') setKotaUyarisi(e.message); else setSaveError('Kaydedilemedi: ' + e.message); }); }; const [kotaUyarisi, setKotaUyarisi] = useState(null); - const planYenile = () => getPlan().then(setPlan).catch(() => {}); + const planYenile = () => { const session=getToken(); return getPlan().then(p => {if(getToken()===session)setPlan(p);}).catch(() => {}); }; const handleLogin = (user) => setAuthUser(user); const handleLogout = () => { @@ -303,21 +313,15 @@ export const AppProvider = ({ children }) => { const [aiAnalyzing, setAiAnalyzing] = useState(false); // Enterprise Module States - // Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (localStorage) - const [competitorWatchlist, setCompetitorWatchlist] = useState(() => { - try { return JSON.parse(localStorage.getItem('ihaleist_rakip_izleme') || '[]'); } catch { return []; } - }); + // Rakip izleme listesi — kullanıcı gerçek kazanan firma analizinden ekler (hesaba özel sunucu kaydı) + const [competitorWatchlist, setCompetitorWatchlist] = useState([]); useEffect(() => { - yerelKaydet('ihaleist_rakip_izleme', competitorWatchlist); if (veriSenkron) ilkYazmaDegil('rakip_izleme', competitorWatchlist); }, [competitorWatchlist, veriSenkron]); - // --- Favori kurumlar (localStorage, gerçek) --- - const [favoriteKurumlar, setFavoriteKurumlar] = useState(() => { - try { return JSON.parse(localStorage.getItem('ihaleist_favori_kurumlar') || '[]'); } catch { return []; } - }); + // --- Favori kurumlar (sunucu kaydı) --- + const [favoriteKurumlar, setFavoriteKurumlar] = useState([]); useEffect(() => { - yerelKaydet('ihaleist_favori_kurumlar', favoriteKurumlar); if (veriSenkron) ilkYazmaDegil('favori_kurumlar', favoriteKurumlar); }, [favoriteKurumlar, veriSenkron]); const toggleFavoriteKurum = (kurum) => { @@ -326,44 +330,19 @@ export const AppProvider = ({ children }) => { setFavoriteKurumlar((prev) => (prev.includes(ad) ? prev.filter((k) => k !== ad) : [...prev, ad])); }; - // --- Ekip yönetimi (localStorage, gerçek CRUD) --- - const [teamMembers, setTeamMembers] = useState(() => { - try { - const saved = localStorage.getItem('ihaleist_ekip'); - if (saved) return JSON.parse(saved); - } catch { /* yoksay */ } - const u = getStoredUser(); - return u ? [{ id: 'tm-1', name: u.name || u.email, email: u.email || '', role: 'Yönetici' }] : []; - }); + // --- Ekip yönetimi (sunucu kaydı) --- + const [teamMembers, setTeamMembers] = useState([]); useEffect(() => { - yerelKaydet('ihaleist_ekip', teamMembers); if (veriSenkron) ilkYazmaDegil('ekip', teamMembers); }, [teamMembers, veriSenkron]); const addTeamMember = (m) => setTeamMembers((prev) => [...prev, { id: 'tm-' + Date.now(), ...m }]); const removeTeamMember = (id) => setTeamMembers((prev) => prev.filter((m) => m.id !== id)); - // Belge kasası — kullanıcı kendi belgelerini ekler (localStorage) - const [vaultDocuments, setVaultDocuments] = useState(() => { - try { return JSON.parse(localStorage.getItem('ihaleist_belgeler') || '[]'); } catch { return []; } - }); - useEffect(() => { - yerelKaydet('ihaleist_belgeler', vaultDocuments); - }, [vaultDocuments]); + // Belge kasası — kullanıcı kendi belgelerini ekler (hesaba özel sunucu kaydı) + const [vaultDocuments, setVaultDocuments] = useState([]); - const [reminderSettings, setReminderSettings] = useState(() => { - const def = { - emailEnabled: true, smsEnabled: true, pushEnabled: true, - notify3DaysBefore: true, notify1DayBefore: true, notify3HoursBefore: true, - googleCalendarSync: true, outlookSync: false, - quietEnabled: false, quietStart: '22:00', quietEnd: '08:00', - }; - try { - const saved = JSON.parse(localStorage.getItem('ihaleist_reminder') || 'null'); - return saved ? { ...def, ...saved } : def; - } catch { return def; } - }); + const [reminderSettings, setReminderSettings] = useState(defaultReminder); useEffect(() => { - yerelKaydet('ihaleist_reminder', reminderSettings); if (veriSenkron) ilkYazmaDegil('reminder', reminderSettings); }, [reminderSettings, veriSenkron]); @@ -441,6 +420,7 @@ export const AppProvider = ({ children }) => { // seçilen model zinciri (fallback) ile. // mode='derin' → ince detaylar, belge kontrol listesi ve yol haritası da üretilir const analyzeSpecPdfWithGemini = async ({ text, fileName, tender, mode } = {}) => { + const session = getToken(); setAiAnalyzing(true); setAiAnalysisResults(null); try { @@ -457,6 +437,7 @@ export const AppProvider = ({ children }) => { attempts: data.attempts, analyzedAt: new Date(data.analyzedAt).toLocaleString('tr-TR'), }; + if(getToken() !== session) throw new Error('Oturum değişti; analiz sonucu bu hesaba aktarılmadı.'); setAiAnalysisResults(result); return result; } finally { @@ -464,33 +445,12 @@ export const AppProvider = ({ children }) => { } }; - const BOS_YETKINLIK = { - scrapedAt: null, extractedSectors: [], extractedProducts: [], extractedCertifications: [], - ihaleKonulari: [], matchScoreBoost: 0, - }; - const [userProfile, setUserProfile] = useState(() => { - // Yetkinlikler uyum skorunun girdisi — sayfa yenilenince sıfırlanmasın - let kayitli = null; - try { kayitli = JSON.parse(localStorage.getItem('ihaleist_firma_profil') || 'null'); } catch { /* yoksay */ } - const u = getStoredUser(); - return { - name: u?.name || u?.email || 'Kullanıcı', - username: (u?.email || '').split('@')[0] || 'kullanici', - company: kayitli?.company || '', - website: kayitli?.website || '', - trialDaysLeft: 7, - role: 'Yönetici Tedarikçi', - scrapedCapabilities: { ...BOS_YETKINLIK, ...(kayitli?.scrapedCapabilities || {}) }, - // Firma eşiği: "uyumlu" filtresi ve bildirim bu skordan itibaren - uyumEsigi: Number.isFinite(kayitli?.uyumEsigi) ? kayitli.uyumEsigi : 60, - }; - }); + const [userProfile, setUserProfile] = useState(() => defaultProfile(getStoredUser())); useEffect(() => { const veri = { company: userProfile.company, website: userProfile.website, scrapedCapabilities: userProfile.scrapedCapabilities, uyumEsigi: userProfile.uyumEsigi, }; - yerelKaydet('ihaleist_firma_profil', veri); if (veriSenkron) ilkYazmaDegil('firma_profil', veri); }, [userProfile.company, userProfile.website, userProfile.scrapedCapabilities, userProfile.uyumEsigi, veriSenkron]); const setUyumEsigi = (v) => setUserProfile((p) => ({ ...p, uyumEsigi: Math.max(0, Math.min(100, Number(v) || 0)) })); @@ -532,6 +492,7 @@ export const AppProvider = ({ children }) => { // Önce gerçek tarama (/api/company/scan: site metni + model), başarısızsa // alan adından tahmin eden eski yol. Sonuç uyum skorunun girdisi olur. const scanCompanyWebsite = async (url) => { + const session = getToken(); let yeni = null; try { const r = await fetch('/api/company/scan', { @@ -556,6 +517,7 @@ export const AppProvider = ({ children }) => { } } catch { /* aşağıda yedek yol */ } if (!yeni) yeni = { ...parseWebsiteCapabilitiesDynamically(url), ihaleKonulari: [], gercekTarama: false }; + if(getToken() !== session) throw new Error('Oturum değişti; firma taraması bu hesaba aktarılmadı.'); setUserProfile((prev) => ({ ...prev, website: url, scrapedCapabilities: yeni })); return yeni; }; @@ -596,7 +558,7 @@ export const AppProvider = ({ children }) => { // boyamaya yetecek kadarını, hataya dayanıklı biçimde saklıyoruz. useEffect(() => { try { - localStorage.setItem(TENDER_CACHE_KEY, JSON.stringify(tenders.slice(0, TENDER_CACHE_LIMIT))); + localStorage.setItem(TENDER_CACHE_KEY, JSON.stringify(tenders.slice(0, TENDER_CACHE_LIMIT).map(publicTender))); } catch { // Kota dolduysa önbelleği bırak — veri zaten sunucudan geliyor. try { localStorage.removeItem(TENDER_CACHE_KEY); } catch { /* yok say */ } @@ -604,7 +566,6 @@ export const AppProvider = ({ children }) => { }, [tenders]); useEffect(() => { - yerelKaydet('ihaleist_saved_filters', savedFilters); if (veriSenkron) ilkYazmaDegil('saved_filters', savedFilters); }, [savedFilters, veriSenkron]); @@ -654,11 +615,12 @@ export const AppProvider = ({ children }) => { useEffect(() => { if (!veriSenkron) return; let iptal = false; + const session = getToken(); const cek = async () => { try { const liste = await bildirimleriGetir(); const yeni = liste.filter((b) => !b.okundu); - if (iptal || !yeni.length) return; + if (iptal || getToken() !== session || !yeni.length) return; for (const b of yeni.slice(0, 5)) notify(b.baslik, b.metin); await bildirimOkundu(yeni.map((b) => b.id)); } catch { /* sessiz */ } @@ -674,9 +636,12 @@ export const AppProvider = ({ children }) => { // fark etmiyor — fikir: MOBIT-DURT). useEffect(() => { if (!veriSenkron) return; + const session = getToken(); + const uyariAnahtari = 'ihaleist_belge_uyari_gunu:' + authUser?.email; const bugun = new Date().toISOString().slice(0, 10); - try { if (localStorage.getItem('ihaleist_belge_uyari_gunu') === bugun) return; } catch { /* yoksay */ } + try { if (localStorage.getItem(uyariAnahtari) === bugun) return; } catch { /* yoksay */ } listDocuments().then((d) => { + if(getToken() !== session) return; const simdi = Date.now(); const dolan = [], yakin = []; for (const b of d?.belgeler || []) { @@ -686,7 +651,7 @@ export const AppProvider = ({ children }) => { } if (dolan.length) notify(`${dolan.length} belgenin süresi dolmuş`, dolan.slice(0, 4).join(', ')); if (yakin.length) notify(`${yakin.length} belgenin süresi 30 gün içinde doluyor`, yakin.slice(0, 4).join(', ')); - try { localStorage.setItem('ihaleist_belge_uyari_gunu', bugun); } catch { /* yoksay */ } + try { localStorage.setItem(uyariAnahtari, bugun); } catch { /* yoksay */ } }).catch(() => {}); // eslint-disable-next-line react-hooks/exhaustive-deps }, [veriSenkron]); diff --git a/src/services/auth.js b/src/services/auth.js index d5ba3a5..54c3ef3 100644 --- a/src/services/auth.js +++ b/src/services/auth.js @@ -53,8 +53,10 @@ export async function restoreSession() { if (!token) return null; try { const res = await fetch('/api/auth/me', { headers: { Authorization: `Bearer ${token}` } }); + if (getToken() !== token) return getStoredUser(); if (!res.ok) { clearSession(); return null; } const data = await res.json(); + if (getToken() !== token) return getStoredUser(); if (data.success) { setSession(token, data.user); return data.user; } clearSession(); return null; diff --git a/src/services/tenderState.js b/src/services/tenderState.js index a20e062..a160342 100644 --- a/src/services/tenderState.js +++ b/src/services/tenderState.js @@ -16,3 +16,7 @@ export function applyTenderStates(tenders, records = {}) { export function tenderSnapshot(t) { return Object.fromEntries(['id','ikn','baslik','kurum','tarih','saat','sehir','alimTuru'].map(k => [k,t[k] ?? ''])); } + +export function publicTender(tender) { + return Object.fromEntries(Object.entries(tender).filter(([key]) => !USER_STATE_FIELDS.includes(key))); +} diff --git a/tests/session-isolation.test.mjs b/tests/session-isolation.test.mjs new file mode 100644 index 0000000..40346a3 --- /dev/null +++ b/tests/session-isolation.test.mjs @@ -0,0 +1,33 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {restoreSession,setSession,getStoredUser,getToken,clearSession} from '../src/services/auth.js'; +import {publicTender,USER_STATE_FIELDS,applyTenderStates} from '../src/services/tenderState.js'; +test('late session restore cannot replace or clear a newer account',async()=>{ + const savedStorage=globalThis.localStorage,savedFetch=globalThis.fetch; + const values=new Map(); + globalThis.localStorage={getItem:k=>values.get(k)??null,setItem:(k,v)=>values.set(k,v),removeItem:k=>values.delete(k)}; + try { + for(const response of [{ok:false},{ok:true,json:async()=>({success:true,user:{email:'a@test'}})}]) { + setSession('session-a',{email:'a@test'}); + let resolve;globalThis.fetch=()=>new Promise(r=>resolve=r); + const pending=restoreSession(); + setSession('session-b',{email:'b@test'}); + resolve(response); + assert.equal((await pending).email,'b@test'); + assert.equal(getToken(),'session-b');assert.equal(getStoredUser().email,'b@test'); + } + setSession('session-a',{email:'a@test'}); + let resolve;globalThis.fetch=()=>new Promise(r=>resolve=r); + const pending=restoreSession();clearSession(); + resolve({ok:true,json:async()=>({success:true,user:{email:'a@test'}})}); + assert.equal(await pending,null);assert.equal(getToken(),null); + }finally{globalThis.localStorage=savedStorage;globalThis.fetch=savedFetch;} +}); +test('shared tender cache contains no preparation state and account changes clear previous state',()=>{ + const source={id:'x',ikn:'2026/x',baslik:'Lab',items:[{ad:'Kit'}]}; + const a={...source,...Object.fromEntries(USER_STATE_FIELDS.map(k=>[k,'private-a']))}; + assert.deepEqual(publicTender(a),source); + assert.deepEqual(applyTenderStates([a].map(publicTender),{}),[source]); + const b=applyTenderStates([a].map(publicTender),{'2026/x':{data:{notes:['private-b']}}}); + assert.deepEqual(b[0].notes,['private-b']);assert.equal(b[0].bidDraft,undefined); +});