feat: справочник компаний кэшируется в localStorage на 4 часа

Раньше он жил только пока открыта страница и загружался заново при каждой перезагрузке.
Теперь между перезагрузками API не дёргается 4 часа; просроченный, битый или записанный
«из будущего» кэш игнорируется и удаляется, недоступное хранилище кэш не ломает.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
gorbenko.pavel
2026-10-02 14:59:12 +05:00
parent 7f5f59fc81
commit cce4dfa4b3
3 changed files with 189 additions and 4 deletions
@@ -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<ICompany[]> {
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<ICompany[]> {
return response.json();
}
// Справочник в рамках сессии не меняется — грузим один раз и отдаём тот же
// промис повторным вызовам (как в departments.service).
/** localStorage, если он есть: в некоторых режимах браузера обращение к нему бросает исключение. */
function browserStorage(): IStorageLike | undefined {
try {
return window.localStorage;
} catch {
return undefined;
}
}
async function loadCompanies(context: WebPartContext): Promise<ICompany[]> {
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<ICompany[]> | undefined;
export function getCompanies(context: WebPartContext): Promise<ICompany[]> {
if (!cache) {
cache = fetchCompanies(context).catch((e) => {
cache = loadCompanies(context).catch((e) => {
cache = undefined;
throw e;
});
@@ -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<string, string>();
// 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();
});
});
@@ -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<ICompany> | 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<ICachedCompanies>;
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 {
// кэш — оптимизация, а не источник данных
}
}