From cce4dfa4b3ea1ba112adaa5019409d584b5b6b08 Mon Sep 17 00:00:00 2001 From: "gorbenko.pavel" Date: Fri, 2 Oct 2026 14:59:12 +0500 Subject: [PATCH] =?UTF-8?q?feat:=20=D1=81=D0=BF=D1=80=D0=B0=D0=B2=D0=BE?= =?UTF-8?q?=D1=87=D0=BD=D0=B8=D0=BA=20=D0=BA=D0=BE=D0=BC=D0=BF=D0=B0=D0=BD?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BA=D1=8D=D1=88=D0=B8=D1=80=D1=83=D0=B5=D1=82?= =?UTF-8?q?=D1=81=D1=8F=20=D0=B2=20localStorage=20=D0=BD=D0=B0=204=20?= =?UTF-8?q?=D1=87=D0=B0=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Раньше он жил только пока открыта страница и загружался заново при каждой перезагрузке. Теперь между перезагрузками API не дёргается 4 часа; просроченный, битый или записанный «из будущего» кэш игнорируется и удаляется, недоступное хранилище кэш не ломает. Co-Authored-By: Claude Sonnet 5.5 --- .../shared/dictionary/companies.service.ts | 30 ++++++- .../shared/dictionary/companiesCache.test.ts | 90 +++++++++++++++++++ .../shared/dictionary/companiesCache.ts | 73 +++++++++++++++ 3 files changed, 189 insertions(+), 4 deletions(-) create mode 100644 src/webparts/requestPortal/shared/dictionary/companiesCache.test.ts create mode 100644 src/webparts/requestPortal/shared/dictionary/companiesCache.ts diff --git a/src/webparts/requestPortal/shared/dictionary/companies.service.ts b/src/webparts/requestPortal/shared/dictionary/companies.service.ts index 82efc0b..4b5bfaf 100644 --- a/src/webparts/requestPortal/shared/dictionary/companies.service.ts +++ b/src/webparts/requestPortal/shared/dictionary/companies.service.ts @@ -1,10 +1,11 @@ import { AadHttpClient, HttpClientResponse } from '@microsoft/sp-http'; import { WebPartContext } from '@microsoft/sp-webpart-base'; import { APPROVAL_API_CLIENT_ID, DICTIONARY_API_BASE_URL } from '../config'; +import { IStorageLike, readCachedCompanies, writeCachedCompanies } from './companiesCache'; import { ICompany } from './models/ICompany'; // GET /Dictionary/Companies — справочник компаний, ответ — плоский массив -// { Id, Name } (без обёртки, как /Dictionary/Departments). +// { Id, Name, Bin } (без обёртки, как /Dictionary/Departments). async function fetchCompanies(context: WebPartContext): Promise { const client = await context.aadHttpClientFactory.getClient(APPROVAL_API_CLIENT_ID); const response: HttpClientResponse = await client.get( @@ -17,12 +18,33 @@ async function fetchCompanies(context: WebPartContext): Promise { return response.json(); } -// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же -// промис повторным вызовам (как в departments.service). +/** localStorage, если он есть: в некоторых режимах браузера обращение к нему бросает исключение. */ +function browserStorage(): IStorageLike | undefined { + try { + return window.localStorage; + } catch { + return undefined; + } +} + +async function loadCompanies(context: WebPartContext): Promise { + const storage = browserStorage(); + const cached = storage ? readCachedCompanies(storage, Date.now()) : undefined; + if (cached) return cached; + + const fresh = await fetchCompanies(context); + if (storage) writeCachedCompanies(storage, fresh, Date.now()); + return fresh; +} + +// Справочник живёт в localStorage 4 часа (см. companiesCache.ts) — между перезагрузками страницы +// к API не ходим. Поверх этого один промис на сессию: параллельные вызовы делят один запрос и не +// читают хранилище заново (как в departments.service). Ошибку не кешируем — следующий вызов +// попробует ещё раз. let cache: Promise | undefined; export function getCompanies(context: WebPartContext): Promise { if (!cache) { - cache = fetchCompanies(context).catch((e) => { + cache = loadCompanies(context).catch((e) => { cache = undefined; throw e; }); diff --git a/src/webparts/requestPortal/shared/dictionary/companiesCache.test.ts b/src/webparts/requestPortal/shared/dictionary/companiesCache.test.ts new file mode 100644 index 0000000..ff349d5 --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/companiesCache.test.ts @@ -0,0 +1,90 @@ +import { + COMPANIES_CACHE_KEY, + COMPANIES_CACHE_TTL_MS, + IStorageLike, + readCachedCompanies, + writeCachedCompanies +} from './companiesCache'; +import { ICompany } from './models/ICompany'; + +class MemoryStorage implements IStorageLike { + public readonly data = new Map(); + // eslint-disable-next-line @rushstack/no-new-null + public getItem(key: string): string | null { + return this.data.has(key) ? (this.data.get(key) as string) : null; + } + public setItem(key: string, value: string): void { + this.data.set(key, value); + } + public removeItem(key: string): void { + this.data.delete(key); + } +} + +const COMPANIES: ICompany[] = [ + { Id: '9de064a4-44c8-88ef-11ec-8284930fad96', Name: 'ТОО TANSU Construction', Bin: '021040005165' }, + { Id: '0a1b2c3d-0000-4000-8000-000000000002', Name: 'ТОО TANSU Строй' } +]; + +const NOW = 1_800_000_000_000; + +describe('кэш справочника компаний', () => { + it('в пустом хранилище кэша нет', () => { + expect(readCachedCompanies(new MemoryStorage(), NOW)).toBeUndefined(); + }); + + it('записанное читается обратно, пока не прошло 4 часа', () => { + const storage = new MemoryStorage(); + writeCachedCompanies(storage, COMPANIES, NOW); + + expect(readCachedCompanies(storage, NOW + 1000)).toEqual(COMPANIES); + expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS - 1)).toEqual(COMPANIES); + }); + + it('через 4 часа кэш устаревает и удаляется', () => { + const storage = new MemoryStorage(); + writeCachedCompanies(storage, COMPANIES, NOW); + + expect(readCachedCompanies(storage, NOW + COMPANIES_CACHE_TTL_MS)).toBeUndefined(); + expect(storage.data.has(COMPANIES_CACHE_KEY)).toBe(false); + }); + + it('срок — ровно 4 часа', () => { + expect(COMPANIES_CACHE_TTL_MS).toBe(14_400_000); + }); + + it('запись из будущего (часы переведены назад) не доверяется', () => { + const storage = new MemoryStorage(); + writeCachedCompanies(storage, COMPANIES, NOW + 60_000); + + expect(readCachedCompanies(storage, NOW)).toBeUndefined(); + }); + + it('битый JSON и неожиданная форма — как отсутствующий кэш', () => { + const broken = new MemoryStorage(); + broken.setItem(COMPANIES_CACHE_KEY, '{не json'); + expect(readCachedCompanies(broken, NOW)).toBeUndefined(); + expect(broken.data.has(COMPANIES_CACHE_KEY)).toBe(false); + + const wrongShape = new MemoryStorage(); + wrongShape.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: NOW, items: [{ Id: 1 }] })); + expect(readCachedCompanies(wrongShape, NOW)).toBeUndefined(); + }); + + it('недоступное хранилище не ломает ни чтение, ни запись', () => { + const throwing: IStorageLike = { + getItem: () => { + throw new Error('запрещено'); + }, + setItem: () => { + throw new Error('переполнено'); + }, + removeItem: () => { + throw new Error('запрещено'); + } + }; + + expect(readCachedCompanies(throwing, NOW)).toBeUndefined(); + expect(() => writeCachedCompanies(throwing, COMPANIES, NOW)).not.toThrow(); + }); +}); diff --git a/src/webparts/requestPortal/shared/dictionary/companiesCache.ts b/src/webparts/requestPortal/shared/dictionary/companiesCache.ts new file mode 100644 index 0000000..d78ae3a --- /dev/null +++ b/src/webparts/requestPortal/shared/dictionary/companiesCache.ts @@ -0,0 +1,73 @@ +import { ICompany } from './models/ICompany'; + +/** + * Ключ в localStorage. Версия в имени: поменяется форма записи — старые кэши просто перестанут + * читаться, а не сломают разбор. + */ +export const COMPANIES_CACHE_KEY = 'rp.dictionary.companies.v1'; + +/** Справочник компаний меняется редко, поэтому живёт в кэше 4 часа. */ +export const COMPANIES_CACHE_TTL_MS = 4 * 60 * 60 * 1000; + +/** Тот минимум localStorage, который нужен кэшу — для подмены в тестах. */ +export interface IStorageLike { + // Сигнатура повторяет DOM-интерфейс Storage, где «нет значения» — это null. + // eslint-disable-next-line @rushstack/no-new-null + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + +interface ICachedCompanies { + savedAt: number; + items: ICompany[]; +} + +function isCompany(value: unknown): value is ICompany { + const company = value as Partial | undefined; + return !!company && typeof company.Id === 'string' && typeof company.Name === 'string'; +} + +/** + * Свежий кэш или undefined. Просроченный, битый или в неожиданной форме кэш считается отсутствующим + * и удаляется: лучше лишний раз сходить в справочник, чем показать мусор. + */ +export function readCachedCompanies(storage: IStorageLike, now: number): ICompany[] | undefined { + // eslint-disable-next-line @rushstack/no-new-null + let raw: string | null; + try { + raw = storage.getItem(COMPANIES_CACHE_KEY); + } catch { + return undefined; + } + if (!raw) return undefined; + + try { + const parsed = JSON.parse(raw) as Partial; + const fresh = + typeof parsed.savedAt === 'number' && + now - parsed.savedAt >= 0 && + now - parsed.savedAt < COMPANIES_CACHE_TTL_MS && + Array.isArray(parsed.items) && + parsed.items.every(isCompany); + if (fresh) return parsed.items as ICompany[]; + } catch { + // битый JSON — как отсутствующий кэш + } + + try { + storage.removeItem(COMPANIES_CACHE_KEY); + } catch { + // хранилище недоступно — удалять нечего + } + return undefined; +} + +/** Кладёт справочник в кэш. Переполнение или запрет хранилища (режим инкогнито) кэш не ломают — просто его не будет. */ +export function writeCachedCompanies(storage: IStorageLike, items: ICompany[], now: number): void { + try { + storage.setItem(COMPANIES_CACHE_KEY, JSON.stringify({ savedAt: now, items } as ICachedCompanies)); + } catch { + // кэш — оптимизация, а не источник данных + } +}