pcp: единообразие времени (UTC) по группе сервисов

Фронт (pcp-tgu-ops-ui):
- тест-страж utcTime.guard.test.ts: запрещает Date.parse / new Date(string) /
  toLocale* без timeZone вне utils/utcTime; тестовые фикстуры переведены на parseUtc.

Бэкенд:
- UTC-now: LocalDateTime.now() -> LocalDateTime.now(ZoneOffset.UTC) по всем сервисам
  (entity-дефолты, воркеры, use case'ы) и в тестовых фикстурах.
- Конвертации к UTC (баги на не-UTC JVM, каждая с регресс-тестом в не-UTC поясе):
  - tgu: VisibilityPayloadParser, SyncSpacecraftFromNsiUseCase (Instant -> UTC LocalDateTime);
  - mission-planing: MissionPlaningService (OffsetDateTime -> UTC через toUtcLocalDateTime);
  - dynamic-plan: RequestServiceClient (OffsetDateTime -> UTC);
  - slots: SlotRepositoryImpl (getObject(LocalDateTime) вместо getTimestamp().toLocalDateTime()).
- Контрактные сериализационные тесты формата на проводе (naive без зоны / Z):
  PlanResponse (tgu), RequestResponseDto (request, эталон), TguPlanning DTO (ui).
- KDoc-инвариант «=UTC» на UI-facing DTO (tgu PlanResponse, ui-service DTO вкладки ТГУ).

Документация: docs/standards/DATETIME_UTC.md (соглашение, таблица сервисов, под-задачи на миграцию к Z),
docs/standards/CHECKLIST.md (пункт про UTC на API).
This commit is contained in:
Дмитрий Соловьев
2026-06-02 21:51:38 +03:00
parent ac2220d063
commit 260583b134
84 changed files with 760 additions and 166 deletions
@@ -0,0 +1,62 @@
import { readdirSync, readFileSync } from "node:fs";
import { join, relative } from "node:path";
import { describe, expect, it } from "vitest";
/**
* Страж инварианта «всё время — UTC» (см. docs/standards/DATETIME_UTC.md).
*
* Сканирует исходники и падает на паттернах, которые трактуют время в поясе
* браузера: «голый» разбор строки (`new Date("...")`, `Date.parse`) и показ через
* `toLocale*` без `timeZone`. Парсинг и форматирование времени бэка должны идти
* только через `utils/utcTime` (`parseUtc`, `formatUtc*`). Этот файл и сам
* `utcTime.ts` — единственные исключения: в них паттерны присутствуют легитимно
* (как реализация утилиты и как строки-описания запретов).
*/
const SRC_DIR = join(process.cwd(), "src");
// Исключения: сам страж (содержит паттерны как строки-описания) и каноническая
// утилита utcTime.ts (легитимная реализация parseUtc/formatUtc*).
const EXCLUDED = ["utcTime.ts", "utcTime.guard.test.ts"];
interface Rule {
readonly name: string;
readonly pattern: RegExp;
}
const RULES: Rule[] = [
{ name: "Date.parse(...) — парсит строку в поясе браузера; используйте parseUtc", pattern: /\bDate\.parse\s*\(/ },
{ name: "new Date(\"...\") со строкой — трактует naive-строку как локальное время; используйте parseUtc", pattern: /new\s+Date\(\s*['"`]/ },
{ name: "formatDateTimeLocal — локальный хелпер пикеров; используйте toUtcInputValue/parseUtc", pattern: /\bformatDateTimeLocal\b/ },
// toLocale* без опций или только с локалью → пояс браузера.
{ name: "toLocale*() без timeZone — показывает в поясе браузера; используйте formatUtc* или { timeZone: \"UTC\" }", pattern: /\.toLocale(String|DateString|TimeString)\(\s*(['"`][^'"`]*['"`]\s*)?\)/ },
// toLocale* с inline-объектом опций, в котором нет timeZone.
{ name: "toLocale*({...}) без timeZone в опциях — показывает в поясе браузера; добавьте timeZone: \"UTC\"", pattern: /\.toLocale(String|DateString|TimeString)\(\s*['"`][^'"`]*['"`]\s*,\s*\{(?![\s\S]*?timeZone)[\s\S]*?\}/ }
];
function collectSourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name);
if (entry.isDirectory()) {
out.push(...collectSourceFiles(full));
} else if (/\.tsx?$/.test(entry.name) && !EXCLUDED.includes(entry.name)) {
out.push(full);
}
}
return out;
}
describe("UTC time invariant", () => {
const files = collectSourceFiles(SRC_DIR);
it("scans the source tree", () => {
expect(files.length).toBeGreaterThan(0);
});
for (const rule of RULES) {
it(`forbids: ${rule.name}`, () => {
const offenders = files.filter((file) => rule.pattern.test(readFileSync(file, "utf8")));
expect(offenders.map((file) => relative(SRC_DIR, file))).toEqual([]);
});
}
});