⚠️ Draft documentation
Work in progress — some sections may be incomplete, typos possible. Last updated: 2026-05-10.
EN | DE | RU | FR | ES

Комментарий: git diff origin/develop -- actions/authentication.test.js

Дата: 2026-05-08

Сравниваемые коммиты:

Этот файл: старая версия на GitHub (237 строк, develop, создан 2019-03-15) → новая версия (157 строк)

Примечание: Ссылки на новый код (SHA e67067aa7) ведут на форк MaurerAnton/projectforge (ветка draft43npm). Ссылки на старый код (SHA 9ed5fbe0f) — на основной репозиторий micromata/projectforge (develop).

Секция 1: Импорты (строки 1–5)

@@ -1,5 +1,4 @@
1 /* eslint-disable */
2-import fetchMock from 'fetch-mock/es5/client';
3-import cookies from 'react-cookies';
+import { vi } from 'vitest';
2 import configureMockStore from 'redux-mock-store';
5-import thunk from 'redux-thunk';
+import { thunk } from 'redux-thunk';

Строка 2: -import fetchMock from 'fetch-mock/es5/client'

Что было: Старый тест использовал fetch-mock — внешнюю библиотеку для подмены fetch (добавлен 2019-03-15). Версия es5/client была нужна для совместимости со старым Jest/CRA. [исходник develop]

Что стало: Удалён. Три причины:

  1. Vitest поставляется со встроенным vi.fn() — не нужно ставить отдельный пакет.
  2. fetch-mock версии ^12.6.0 [package.json develop] имеет API, несовместимый с современными fetch-стандартами и требует полифиллов [fetch-mock source on GitHub].
  3. Зависимость fetch-mock отсутствует — на один пакет меньше, меньше уязвимостей.

Строка 3: -import cookies from 'react-cookies'

Что было: Библиотека для чтения/записи кук в браузере react-cookies@0.1.1 (добавлена 2019-03-09, package.json develop [source on GitHub]). Использовалась в старых тестах для проверки KEEP_SIGNED_IN (строка 94, строка 132, строка 183).

Почему удалён: Куки — HTTP-only, JavaScript их не читает (а в тесте читали cookies.loadAll(), который вызывает cookie.parse(document.cookie) — это реальные куки браузера, не мок). fetch-mock кладёт Set-Cookie в заголовки Response, но в document.cookie они не попадают. Значит cookies.loadAll() всегда возвращает {} — пустоту. Тест проходил не потому что кук не было, а потому что fetch-mock и react-cookies живут в разных мирах. react-cookies больше нет в зависимостях.

Строка 3 (новая): +import { vi } from 'vitest'

Что стало: Vitest'овый API для моков. Аналог jest.fn() [Jest docs], но из самого Vitest [vitest.dev] (версия ^4.1.5 [package.json] ).

Основные методы, используемые в тесте:

Строка 5: -import thunk … +import { thunk }

Что изменилось: Импорт redux-thunk с дефолтного (import thunk from) на именованный (import { thunk } from).

В redux-thunk версии 3.x (^3.1.0 [source: export const thunk]) экспорт по умолчанию удалён — теперь нужен именованный { thunk }.

configureMockStore([thunk])middleware, который позволяет диспатчить функции (thunk'и) вместо простых объектов. Без него store.dispatch(login(...)) не сработает.

Секция 2: Импорты из './authentication' (строки 6–15)

@@ -6,13 +5,10 @@
6 import {
7- loadSessionIfAvailable,
8- login,
9- logout,
5 USER_LOGIN_BEGIN,
6 USER_LOGIN_FAILURE,
7 USER_LOGIN_SUCCESS,
12- USER_LOGOUT,
9 userLoginBegin,
10 userLoginFailure,
11 userLoginSuccess,
17- userLogout,
+ login,
+ loadUserStatus,
14 } from './authentication';

Удалены из импорта:

Добавлены в импорт:

Замечание: Старый тестовый файл импортировал 5 символов, которых нет в исходном authentication.js (loadSessionIfAvailable, logout, USER_LOGOUT, userLogout, storeLoginSession). Тест не проходил 7 лет — символы были удалены из исходника (userLogout/logout удалены Jul 2019, loadSessionIfAvailable переименован Mar 2019), но тест не обновили.

Секция 3: Конфигурация (строки 20–28)

@@ -20,77 +15,33 @@
20-describe('login', () => {
21- const username = 'demo';
22- const password = 'demo123';
23-
24- Object.freeze(username);
25- Object.freeze(password);
26-
27- const mockStore = configureMockStore([thunk]);
+const mockStore = configureMockStore([thunk]);
29- afterEach(() => fetchMock.restore());

mockStore вынесен на уровень модуля — был создан 3 раза (внутри login, logout, check session), теперь один раз в начале файла. [redux-mock-store]

Object.freeze()[MDN]. Старый тест замораживал username и password (ни к чему — примитивы неизменяемы). В новом файле Object.freeze используется только на объектах состояния в reducer-тесте, где это действительно важно (защита от мутации в pure function).

afterEach(() => fetchMock.restore())beforeEach(() => vi.restoreAllMocks()) — замена специфичного для fetch-mock вызова на универсальный Vitest-овый vi.restoreAllMocks(). Сброс перенесён с «после» на «до» — гарантия что моки не перетекают между тестами.

Секция 4: Action creator тесты (строки 29–43)

@@ -98,112 +89,133 @@
31- it('should create an action to start the login', () => {
32- const expectedAction = {
33- type: USER_LOGIN_BEGIN,
34- };
35-
36- expect(userLoginBegin())
37- .toEqual(expectedAction);
+describe('action creators', () => {
+ it('userLoginBegin', () => {
+ expect(userLoginBegin()).toEqual({ type: USER_LOGIN_BEGIN });
+ });
40- it('should create an action to mark the login as success', () => {
41- const expectedAction = {
42- type: USER_LOGIN_SUCCESS,
43- };
44-
45- expect(userLoginSuccess())
46- .toEqual(expectedAction);
+ it('userLoginSuccess', () => {
+ expect(userLoginSuccess('user', '1.0', '2024', undefined))
+ .toEqual({
+ type: USER_LOGIN_SUCCESS,
+ payload: { user: 'user', version: '1.0', buildTimestamp: '2024', alertMessage: undefined },
+ });
+ });
49- it('should create an action to mark the login as success', () => {
+ it('userLoginFailure', () => {
+ expect(userLoginFailure('Some error'))
+ .toEqual({
+ type: USER_LOGIN_FAILURE,
+ payload: { error: 'Some error' },
+ });
+ });
+});

Три теста action creator'ов, сгруппированные в describe('action creators').

ТестСтарыйНовыйРазница
userLoginBegin 11 строк, переменная expectedAction 3 строки, inline Только стиль. Логика не изменилась.
userLoginSuccess Вызов без аргументов: userLoginSuccess(). Ожидание: { type: USER_LOGIN_SUCCESS } [строка 45] Вызов с 4 аргументами. Ожидание: полный payload с user, version, buildTimestamp, alertMessage [новый] Исправление: userLoginSuccess принимает 4 обязательных параметра (authentication.js:11). Старый тест проверял неполное поведение.
userLoginFailure 10 строк, описание с опечаткой «mark the login as success» [строка 49] 5 строк, корректное описание Исправлена опечатка. Логика без изменений.

🔍 Опечатка в оригинале: два теста с одинаковым названием

Строка 40 — SUCCESS ✓ Строка 49 — FAILURE, но название «success» ✗
it('should create an action to mark
    the login as success', () => {
  const expectedAction = {
    type: USER_LOGIN_SUCCESS
  };
  expect(userLoginSuccess())
    .toEqual(expectedAction);
});
it('should create an action to mark
    the login as success', () => {
  const expectedAction = {
    type: USER_LOGIN_FAILURE,
    payload: { error: 'Some uncool...' }
  };
  expect(userLoginFailure('Some...'))
    .toEqual(expectedAction);
});

Так и висело 7 лет — название врало о содержимом. В новой версии имена приведены в соответствие.

Секция 5: Старый тест логина без keepSignedIn (строки 61–97)

Что такое keepSignedIn / stayLoggedIn: Флажок «оставаться в системе» — чекбокс на форме логина. Если пользователь его отмечает, происходит следующая цепочка:

  1. Фронтенд: login(username, password, keepSignedIn) (authentication.js:65) → тело POST: { stayLoggedIn: keepSignedIn } (строка 77)
  2. Контроллер: LoginPageRest.kt принимает PostData<LoginData> (строка 96), поле stayLoggedIn в LoginData.kt:34
  3. Сервис: LoginService.authenticate() проверяет if (loginData.stayLoggedIn == true) (LoginService.kt:175), генерирует токен STAY_LOGGED_IN_KEY (UserTokenType.kt:31) и вызывает addStayLoggedInCookie() (строка 177-178)
  4. Кука: CookieService ставит куку с именем "stayLoggedIn" (CookieService.kt:201) и сроком 30 дней (строка 200)
  5. Восстановление: при следующем открытии, если JSESSIONID истекла, LoginService.checkStayLoggedIn() (LoginService.kt:250) читает куку через CookieService (CookieService.kt:65-75) — сервер авторизует пользователя без пароля

Старый тест пытался проверить этот флажок через cookies.loadAll() — но кука не попадает в document.cookie при mock-окружении (см. секцию 2).

@@ -98,112 +89,133 @@
61- it('creates USER_LOGIN_SUCCESS when fetching login has been done without keepSignedIn', () => {
62- fetchMock
63- .mock(
64- (url, options) => {
65- if (url !== '/rsPublic/login' || options.method !== 'POST') {
66- return false;
67- }
68-
69- const body = JSON.parse(options.body);
70-
71- return body.username === username
72- && body.password === password
73- && !body.stayLoggedIn;
74- },
75- {
76- status: 200,
77- headers: { 'Set-Cookie': 'JSESSIONID=ABCDEF0123456789' },
78- },
79- )
80- .catch({ throws: new Error('mock failed') });
81-
82- const expectedActions = [
83- { type: USER_LOGIN_BEGIN },
84- { type: USER_LOGIN_SUCCESS },
85- ];
86-
87- const store = mockStore({});
88-
89- return store.dispatch(login(username, password, false))
90- .then(() => {
91- expect(store.getActions())
92- .toEqual(expectedActions);
93-
94- expect(cookies.loadAll())
95- .toEqual({});
96- });
97- });

Проблемы старого теста:

  1. fetchMock.mock(matcher, response) — громоздкий API: функция-матчер сама парсит body запроса и проверяет URL. [строка 62-80] [fetch-mock source]
  2. headers: { 'Set-Cookie': '...' } — симулирует установку сессионной куки, но fetch-mock не умеет реально их устанавливать [строка 77]. Почему: fetch-mock возвращает объект Response с заголовками — но не трогает document.cookie. А react-cookies.loadAll() читает именно document.cookie = cookie.parse(document.cookie). Цепочка: fetch-mock → Set-Cookie в заголовках → document.cookie не обновлён → loadAll() возвращает {} → тест проходит, но проверяет пустоту, а не реальное поведение.
  3. .catch({ throws: new Error('mock failed') }) — если запрос не совпал ни с одним матчером — бросить ошибку. Костыль, который маскирует настоящие проблемы [строка 80].
  4. expectedActions = [BEGIN, SUCCESS] — НЕВЕРНО. login() диспатчит BEGIN, потом вызывает loadUserStatus()(dispatch), который диспатчит ещё один BEGIN и потом SUCCESS. Итого должно быть 3 action'а, а не 2. [authentication.js:65-84]loginloadUserStatus()(dispatch) внутри .then().
  5. cookies.loadAll()toEqual({}) — тестирует браузерную библиотеку react-cookies, а не логику приложения [строка 94].

🏗 Архитектурный анализ: почему старый тест был обречён

Тест на неправильном слое абстракции. Старый тест пытался проверить поведение кук — но куки управляются браузером, а не fetch():

❌ Замоканный тест (jsdom)
fetch-mock ──→  Response {
  headers: { 'Set-Cookie': 'JSESSIONID' }
}
      │
      │ Set-Cookie НЕ обрабатывается
      ▼
document.cookie = ""

react-cookies.loadAll() ──→  {}
expect(cookies.loadAll()).toEqual({})  ✓
Тест прошёл — проверил пустоту, не поведение
✓ Реальный браузер
fetch() ──→  HTTP Response {
  Set-Cookie: JSESSIONID=ABC
}
      │
      │ Браузер обрабатывает Set-Cookie
      ▼
document.cookie = "JSESSIONID=ABC"

react-cookies.loadAll() ──→  { JSESSIONID: "ABC" }
Кука видна — реальное поведение

Два несовместимых мира моков. fetch-mock и react-cookies работают на принципиально разных уровнях браузерного стека и никак не связаны в тестовом окружении. fetch-mock подменяет HTTP-слой (сеть), а react-cookies читает DOM-слой (document.cookie). В реальном браузере между ними стоит cookie jar — компонент браузера, который обрабатывает Set-Cookie из HTTP-ответа и записывает в document.cookie. В jsdom с замоканным fetch этого слоя нет — и тест проверял иллюзию.

Тест проходил по совпадению. Браузер не обрабатывал Set-Cookiedocument.cookie пуст → cookies.loadAll() возвращал {}. Но тест и ожидал {}! Совпадение двух нулей — пустота совпала с пустотой — создавало иллюзию работающего теста.

Граница ответственности. Юнит-тест фронтенда должен проверять какие Redux-действия диспатчатся при разных ответах fetch, а не как браузер обрабатывает HTTP-заголовки. Правильный тест: «fetch вернул 200 → диспатчнули [BEGIN, BEGIN, SUCCESS]». Неправильный: «fetch вернул Set-Cookie → проверим что кука записалась в document.cookie» — это тест браузера, не кода приложения.

Секция 6: Старый тест с keepSignedIn и баг /√/login (строки 99–137)

@@ -98,112 +89,133 @@
99- it('creates USER_LOGIN_SUCCESS when fetching login has been done with keepSignedIn', () => {
100- fetchMock
101- .mock(
102- (url, options) => {
103- if (url !== '/√/login' || options.method !== 'POST') {
104- return false;
105- }
106-
107- const body = JSON.parse(options.body);
108-
109- return body.username === username
110- && body.password === password
111- && body.stayLoggedIn;
112- },
113- {
114- status: 200,
115- headers: { 'Set-Cookie': 'JSESSIONID=ABCDEF0123456789' },
116- },
117- )
118- .catch({ throws: new Error('mock failed') });
119-
120- const expectedActions = [
121- { type: USER_LOGIN_BEGIN },
122- { type: USER_LOGIN_SUCCESS },
123- ];
124-
125- const store = mockStore({});
126-
127- return store.dispatch(login(username, password, true))
128- .then(() => {
129- expect(store.getActions())
130- .toEqual(expectedActions);
131-
132- expect(cookies.loadAll())
133- .toEqual({
134- KEEP_SIGNED_IN: true,
135- });
136- });
137- });

Критический баг в оригинале:

if (url !== '/√/login' || options.method !== 'POST') — URL написан как /√/login (символ квадратного корня U+221A), хотя правильный URL — /rsPublic/login. [строка 103]

Этот тест никогда не срабатывал — запрос POST /rsPublic/login не совпадал с матчером /√/login, поэтому падал в .catch({ throws: new Error('mock failed') }).

Однако тест мог проходить, если ошибка подавлялась в цепочке .then().catch()catchError в authentication.js:84 диспатчит FAILURE, а не пробрасывает исключение.

Секция 7: Старый тест логин-фейл (строки 139–161)

@@ -139,24 +131,14 @@
139- it('creates USER_LOGIN_FAILURE when fetching login has been failed', () => {
140- fetchMock
141- .mock('/rsPublic/login', 401)
142- .catch(() => {
143- throw new Error('mock failed');
144- });
145-
146- const expectedActions = [
147- { type: USER_LOGIN_BEGIN },
148- {
149- type: USER_LOGIN_FAILURE,
150- payload: { error: 'Unauthorized' },
151- },
152- ];
153-
154- const store = mockStore({});
155-
156- return store.dispatch(login(username, password, false))
157- .then(() => {
158- expect(store.getActions())
159- .toEqual(expectedActions);
160- });
161- });
162-});

Проблемы:

  1. fetchMock.mock('/rsPublic/login', 401) — старый API, просто URL и статус [строка 141]. Возвращал { status: 401 } без ok: false, а handleHTTPErrors проверяет response.ok [rest.js:32].
  2. payload: { error: 'Unauthorized' } — в реальном коде handleHTTPErrors кидает Error('Fetch failed: Error 401') [rest.js:34]. Тест ожидает неверное сообщение.

Секция 8: Новый describe('login') — успешный логин (строки 44–75)

@@ -39,24 +44,106 @@
+describe('login', () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ it('dispatches BEGIN + BEGIN + SUCCESS for valid credentials', async () => {
+ const userData = { username: 'demo', admin: false };
+ const systemData = { version: '2.0.0', buildTimestamp: '2025-01-01 00:00' };
+
+ global.fetch = vi.fn()
+ .mockResolvedValueOnce(
+ { ok: true, status: 200, json: () => Promise.resolve({}) },
+ )
+ .mockResolvedValueOnce({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ userData, systemData, alertMessage: undefined }),
+ });
+
+ const store = mockStore({});
+ await store.dispatch(login('demo', 'demo123', false));
+
+ expect(store.getActions()).toEqual([
+ { type: USER_LOGIN_BEGIN },
+ { type: USER_LOGIN_BEGIN },
+ {
+ type: USER_LOGIN_SUCCESS,
+ payload: {
+ user: userData,
+ version: systemData.version,
+ buildTimestamp: systemData.buildTimestamp,
+ alertMessage: undefined,
+ },
+ },
+ ]);
+ });

async/await — замена .then() на async/await. Короче, читаемее, ошибки не проглатываются.

global.fetch = vi.fn().mockResolvedValueOnce() — два вызова fetch подменяются по очереди:

  1. Первый: ответ на POST /rsPublic/login — пустой {}
  2. Второй: ответ на GET /rs/userStatus (из loadUserStatus() внутри login()) — с userData, systemData, alertMessage

[BEGIN, BEGIN, SUCCESS] — ИСПРАВЛЕНО. Три action'а:

Старый тест ожидал 2 ([BEGIN, SUCCESS]) — не хватало второго BEGIN.

Секция 9: Новый describe('login') — неверный пароль и сеть (строки 76–106)

@@ -44,14 +106,17 @@
+ it('dispatches BEGIN + FAILURE for invalid credentials', async () => {
+ global.fetch = vi.fn()
+ .mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: () => Promise.resolve({}),
+ });
+
+ const store = mockStore({});
+ await store.dispatch(login('demo', 'wrong', false));
+
+ expect(store.getActions()).toEqual([
+ { type: USER_LOGIN_BEGIN },
+ { type: USER_LOGIN_FAILURE, payload: { error: 'Fetch failed: Error 401' } },
+ ]);
+ });

Неверный пароль (401):

@@ -61,9 +124,10 @@
+ it('dispatches BEGIN + FAILURE for network error', async () => {
+ global.fetch = vi.fn()
+ .mockRejectedValue(new Error('Network error'));
+
+ const store = mockStore({});
+ await store.dispatch(login('demo', 'demo123', false));
+
+ expect(store.getActions()).toEqual([
+ { type: USER_LOGIN_BEGIN },
+ { type: USER_LOGIN_FAILURE, payload: { error: 'Network error' } },
+ ]);
+ });
+});

Сеть упала — НОВЫЙ ТЕСТ (не было в старом файле):

mockRejectedValue(new Error('Network error'))[vitest docs]. Симулирует ошибку сети/DNS. fetch не возвращает ответ, а бросает исключение. login()fetch(...) → сеть упала → .catch(catchError(dispatch))dispatch(USER_LOGIN_FAILURE('Network error')). [authentication.js:84]

Секция 10: Удалён describe('logout') (строки 164–192)

@@ -164,30 +143,14 @@
164-describe('logout', () => {
165- const mockStore = configureMockStore([thunk]);
166-
167- it('should create USER_LOGOUT action', () => {
168- const expectedAction = {
169- type: USER_LOGOUT,
170- };
171-
172- expect(userLogout())
173- .toEqual(expectedAction);
174- });
175-
176- it('creates USER_LOGOUT during logout', () => {
177- const expectedActions = [
178- { type: USER_LOGOUT },
179- ];
180-
181- const store = mockStore({});
182-
183- cookies.save('KEEP_SIGNED_IN', 'ABCDEF');
184-
185- store.dispatch(logout());
186-
187- expect(store.getActions())
188- .toEqual(expectedActions);
189-
190- expect(cookies.loadAll())
191- .toEqual({});
192- });
193-});

Удалён целиком (2 теста, ~30 строк).

Причины:

  1. userLogout() возвращает { type: USER_LOGOUT } — тривиально, тот же уровень что и action creator. USER_LOGOUT не экспортится из authentication.js [только 3 типа].
  2. logout() диспатчит один action — нет асинхронного кода.
  3. Оба зависят от cookies.loadAll() (react-cookies), который удалён.
  4. Функциональность логаута перекрывается USER_LOGIN_BEGIN (сброс состояния).

Секция 11: Старый → Новый loadUserStatus (строки 195–236 → 107–157)

@@ -195,43 +158,17 @@
195-describe('check session', () => {
+describe('loadUserStatus', () => {
196- const mockStore = configureMockStore([thunk]);
197-
198- afterEach(() => fetchMock.restore());
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ });
200- it('creates no action at all', () => {
201- const store = mockStore({});
202-
203- expect(store.dispatch(loadSessionIfAvailable()))
204- .toEqual(null);
205- expect(store.getActions())
206- .toEqual([]);
207- });
208-
209- it('creates USER_LOGIN_SUCCESS', () => {
210- fetchMock
211- // TODO: ADD AUTHENTICATION TEST ENDPOINT
212- .getOnce('/rs/userStatus', 200)
213- .catch((url, a, b) => {
214- throw new Error('mock failed');
215- });
216-
217- const expectedActions = [
218- { type: USER_LOGIN_BEGIN },
219- { type: USER_LOGIN_SUCCESS },
220- ];
221-
222- cookies.save('KEEP_SIGNED_IN', true);
223-
224- const store = mockStore({});
225-
226- return store.dispatch(loadSessionIfAvailable())
227- .then(() => {
228- expect(store.getActions())
229- .toEqual(expectedActions);
230-
231- expect(cookies.loadAll())
232- .toEqual({
233- KEEP_SIGNED_IN: true,
234- });
235- });
236- });
237-});

Ключевой TODO: 7 лет.

// TODO: ADD AUTHENTICATION TEST ENDPOINT [строка 211]добавлен 2019-03-17, висел 7 лет до PR (удалён в 7e78f3741). Старый тест loadSessionIfAvailable возвращал только статус 200, без JSON body. response.json() ломался. Тест никогда не проходил.

Удалён «creates no action at all» — проверял что loadSessionIfAvailable() возвращает null без мока. В текущем коде loadUserStatus() всегда делает fetch.

Новый describe('loadUserStatus') — валидная сессия (строки 108–140)

@@ -158,24 +133,28 @@
+ it('dispatches BEGIN + SUCCESS on valid session', async () => {
+ const userData = { username: 'existinguser', admin: true };
+ const systemData = { version: '2.0.0', buildTimestamp: '2025-05-05 10:00' };
+ const alertMessage = 'Some alert';
+
+ global.fetch = vi.fn()
+ .mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: () => Promise.resolve({ userData, systemData, alertMessage }),
+ });
+
+ const store = mockStore({});
+ await store.dispatch(loadUserStatus());
+
+ expect(store.getActions()).toEqual([
+ { type: USER_LOGIN_BEGIN },
+ {
+ type: USER_LOGIN_SUCCESS,
+ payload: {
+ user: userData,
+ version: systemData.version,
+ buildTimestamp: systemData.buildTimestamp,
+ alertMessage,
+ },
+ },
+ ]);
+ });

Правильный JSON ответ: json: () => Promise.resolve({ userData, systemData, alertMessage }) — структура соответствует тому что ожидает loadUserStatus() в authentication.js:42.

alertMessage: 'Some alert' — проверяем проброс системного сообщения (в login-тесте было undefined — разные ветки).

Новый describe('loadUserStatus') — сессия истекла (строки 142–157)

@@ -170,9 +147,12 @@
+ it('dispatches BEGIN + FAILURE on session expired', async () => {
+ global.fetch = vi.fn()
+ .mockResolvedValue({
+ ok: false,
+ status: 401,
+ json: () => Promise.resolve({}),
+ });
+
+ const store = mockStore({});
+ await store.dispatch(loadUserStatus());
+
+ const actions = store.getActions();
+ expect(actions[0]).toEqual({ type: USER_LOGIN_BEGIN });
+ expect(actions[1]).toEqual({ type: USER_LOGIN_FAILURE, payload: { error: undefined } });
+ });
+});

Новый тест — не было в старом файле.

payload: { error: undefined } — особенность loadUserStatus(): в catch-обработчике вызывается catchError(dispatch)({ message: undefined }). catchError = (dispatch) => (error) => dispatch(userLoginFailure(error.message))error.message это undefined, потому что передан объект { message: undefined }.

Проверка через actions[0] и actions[1] (не toEqual всего массива) — потому что loadUserStatus() при ошибке делает window.location.href = (редирект на /login), который в jsdom может не отработать.

Понятия

Redux thunk
Middleware, позволяющий диспатчить функции (а не только объекты). Функция получает (dispatch, getState) — может делать fetch и диспатчить несколько action'ов. login() и loadUserStatus() — это thunk'и: они возвращают function(dispatch) { ... }. Без middleware redux-thunk Redux отклонил бы их с ошибкой. [документация]
Action creator → Thunk
Action creator (userLoginBegin()) возвращает объект { type, payload } — диспатчится синхронно. Thunk (login(username, password)) возвращает функцию — middleware запускает её асинхронно, она делает fetch и диспатчит несколько action'ов. Это два разных слоя: creator'ы — синхронная фабрика объектов, thunk'и — асинхронный оркестратор.
JSESSIONID
Стандартная кука Java-сессии. Создаётся не кодом ProjectForge, а сервлет-контейнером (Tomcat/Jetty) при первом request.getSession(true). ProjectForge лишь управляет её жизненным циклом:
  • После логина — инвалидация и пересоздание (защита от session fixation): LoginService.kt:333-342
    // Session Fixation: Change JSESSIONID after login
    request.getSession(false)?.let { session ->
        if (!session.isNew) { session.invalidate() }
    }
    val session = request.getSession(true)  // ← новый JSESSIONID
    session.setAttribute(SESSION_KEY_USER, userContext)
  • Отправка обратно — каждая fetch() шлёт куку через credentials: 'include' в authentication.js:37:
    fetch(getServiceURL('userStatus'), {
        method: 'GET',
        credentials: 'include',  // ← отправляет JSESSIONID на сервер
    })
Когда истекает — сессия мертва, GET /rs/userStatus возвращает 401. Кука HTTP-only: JavaScript не может прочитать её через document.cookie. [Wikipedia: Session ID]
KEEP_SIGNED_IN / stayLoggedIn
Флажок «оставаться в системе» на форме логина. Фронтенд: параметр keepSignedIn в authentication.js:65 → отправляется как { stayLoggedIn: true } в теле POST (строка 77). Сервер генерирует токен STAY_LOGGED_IN_KEY и ставит куку с именем "stayLoggedIn" (CookieService.kt:201). KEEP_SIGNED_INстарое имя константы из client-side кода, удалена в Mar 2019. Старый тест использовал именно его (cookies.save('KEEP_SIGNED_IN', ...)) — неверное имя + fetch-mock не устанавливает куки = тест проверял пустоту.
HTTP-only cookie
Кука с флагом HttpOnly. Браузер отправляет её на сервер, но JavaScript не имеет к ней доступа — document.cookie её не содержит. JSESSIONID — HTTP-only (защита от XSS-атак). Именно поэтому cookies.loadAll() (который читает document.cookie) никогда не видит сессионную куку. [MDN]
vi.fn()
Vitest-овский конструктор мок-функции. Аналог jest.fn(). mockResolvedValueOnce(x)следующий вызов вернёт Promise.resolve(x) (каскад). mockResolvedValue(x)все вызовы вернут Promise.resolve(x). mockRejectedValue(e) — вызов бросит исключение (сеть упала). Разница Once / без Once критична: в тесте логина два вызова fetch подменяются по очереди. [vitest]
import.meta.env.DEV / MODE
Vite-аналог process.env.NODE_ENV. import.meta.env.DEVtrue в dev-режиме. import.meta.env.MODE — строка: 'development', 'production', или 'test'. Замена понадобилась потому что CRA-переменные process.env.NODE_ENV не существуют в Vite. В rest.js:10: DEV && MODE !== 'test' — dev-сервер, но не тестовый раннер. [vite docs]
connect() → useSelector/useDispatch
Два способа связи React-компонента с Redux-стором. connect(mapStateToProps)(Component) — HOC-паттерн (до 2019), оборачивает компонент. useSelector(state => state.user) + useDispatch() — хуки (React 16.8+), проще, работают с TypeScript, tree-shakeable. loggedIn: boolean сменился на user: object|null именно из-за перехода на хуки: селектор state => state.authentication.user !== null читает конкретное поле вместо флага. [react-redux]
loadUserStatus()
Thunk, вызываемый при старте приложения и после логина. Делает GET /rs/userStatus с кукой (credentials: 'include'). Сервер проверяет сессию: если жива — возвращает { userData, systemData, alertMessage } (200). Если истекла — 401 → редирект на /react/public/login. Это первое что делает React при загрузке (см. ProjectForge.jsx): «кто я?» [authentication.js:30]

Сводка изменений

АспектСтарый файл (237 строк)Новый файл (157 строк)
Mock fetch fetch-mock [строка 2] vi.fn() [vitest]
Куки react-cookies [строка 3], тесты stayLoggedIn Удалены — браузер не тестируется
Async стиль Promise .then() [MDN] async/await [MDN]
Структура describe 3 блока: login, logout, check session 3 блока: action creators, login, loadUserStatus
login валидный 2: [BEGIN, SUCCESS]неверно [строка 82] 3: [BEGIN, BEGIN, SUCCESS]верно [auth.js:62-84]
login неверные данные 'Unauthorized' — баг URL /√/login [строка 103] 'Fetch failed: Error 401' — правильный URL [rest.js:32-38]
Сеть упала ❌ Нет теста mockRejectedValue [строка 96]
loadUserStatus валидная сессия TODO: 7 лет, не работал [строка 211] ✅ Полноценный тест [строки 108-140]
loadUserStatus истекшая сессия ❌ Нет теста ✅ 401 → FAILURE [строки 142-157]
logout 2 теста, символ не существует в исходнике [удалён Jul 2019] Удалён — тривиальный action, покрыт USER_LOGIN_BEGIN

— ссылка на код в ветке fix/vite-eslint-upgrade, не слитой в develop. Код может быть не доступен на GitHub до слияния PR.