mirror of
https://github.com/kamask-dev/pchelpmaster.git
synced 2026-11-11 13:59:38 +00:00
feat(shared): add pocketbase di client and domain errors
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014hvR7iG75jK5byaBBHdDLT
This commit is contained in:
co-authored by
Claude Sonnet 5.5
parent
fc55456286
commit
bec31ff02e
@@ -2,9 +2,10 @@ import { mergeApplicationConfig, ApplicationConfig } from '@angular/core';
|
||||
import { provideServerRendering, withRoutes } from '@angular/ssr';
|
||||
import { appConfig } from './app.config';
|
||||
import { serverRoutes } from './app.routes.server';
|
||||
import { providePbUrlForServer } from './shared/pocketbase/pb-url';
|
||||
|
||||
const serverConfig: ApplicationConfig = {
|
||||
providers: [provideServerRendering(withRoutes(serverRoutes))],
|
||||
providers: [provideServerRendering(withRoutes(serverRoutes)), providePbUrlForServer()],
|
||||
};
|
||||
|
||||
export const config = mergeApplicationConfig(appConfig, serverConfig);
|
||||
|
||||
@@ -2,11 +2,13 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { routes } from './app.routes';
|
||||
import { provideClientHydration } from '@angular/platform-browser';
|
||||
import { providePocketBase } from './shared/pocketbase/pocketbase-client';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideBrowserGlobalErrorListeners(),
|
||||
provideRouter(routes),
|
||||
provideClientHydration(),
|
||||
providePocketBase(),
|
||||
],
|
||||
};
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// Доменные ошибки, понятные UI. Из ошибок PocketBase SDK их делает toDomainError().
|
||||
|
||||
/** Общий предок доменных ошибок: по нему toDomainError() узнаёт уже переведённую ошибку. */
|
||||
export class DomainError extends Error {}
|
||||
|
||||
export class NotFoundError extends DomainError {}
|
||||
|
||||
export class ValidationError extends DomainError {
|
||||
/** Код ошибки PocketBase по каждому полю, например `{ email: 'validation_invalid_email' }`. */
|
||||
constructor(
|
||||
readonly fieldErrors: Record<string, string>,
|
||||
options?: ErrorOptions,
|
||||
) {
|
||||
super(undefined, options);
|
||||
}
|
||||
}
|
||||
|
||||
export class UnauthorizedError extends DomainError {}
|
||||
|
||||
/** Неверный email или пароль; создаёт сервис аутентификации, toDomainError() её не знает. */
|
||||
export class InvalidCredentialsError extends DomainError {}
|
||||
|
||||
export class NetworkError extends DomainError {}
|
||||
|
||||
export class UnknownError extends DomainError {}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { ClientResponseError } from 'pocketbase';
|
||||
import {
|
||||
DomainError,
|
||||
NetworkError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
UnknownError,
|
||||
ValidationError,
|
||||
} from './domain-errors';
|
||||
import { toDomainError } from './to-domain-error';
|
||||
|
||||
function pbError(status: number, data: Record<string, unknown> = {}): ClientResponseError {
|
||||
return new ClientResponseError({ status, response: { status, message: 'Ошибка', data } });
|
||||
}
|
||||
|
||||
describe('toDomainError', () => {
|
||||
it('404 → NotFoundError', () => {
|
||||
expect(toDomainError(pbError(404))).toBeInstanceOf(NotFoundError);
|
||||
});
|
||||
|
||||
it('400 с ошибками по полям → ValidationError с кодами по полям', () => {
|
||||
const error = toDomainError(
|
||||
pbError(400, {
|
||||
email: { code: 'validation_invalid_email', message: 'Must be a valid email.' },
|
||||
password: { code: 'validation_length_out_of_range', message: 'Too short.' },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(error).toBeInstanceOf(ValidationError);
|
||||
expect((error as ValidationError).fieldErrors).toEqual({
|
||||
email: 'validation_invalid_email',
|
||||
password: 'validation_length_out_of_range',
|
||||
});
|
||||
});
|
||||
|
||||
it('400 без ошибок по полям → UnknownError с исходной ошибкой в cause', () => {
|
||||
const original = pbError(400);
|
||||
|
||||
const error = toDomainError(original);
|
||||
|
||||
expect(error).toBeInstanceOf(UnknownError);
|
||||
expect(error.cause).toBe(original);
|
||||
});
|
||||
|
||||
it.each([401, 403])('%i → UnauthorizedError', (status) => {
|
||||
expect(toDomainError(pbError(status))).toBeInstanceOf(UnauthorizedError);
|
||||
});
|
||||
|
||||
it('status 0 (нет связи) → NetworkError', () => {
|
||||
expect(toDomainError(new ClientResponseError({ status: 0 }))).toBeInstanceOf(NetworkError);
|
||||
});
|
||||
|
||||
it('прерванный запрос → NetworkError', () => {
|
||||
expect(toDomainError(new ClientResponseError({ isAbort: true }))).toBeInstanceOf(
|
||||
NetworkError,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([429, 500])('%i → UnknownError с исходной ошибкой в cause', (status) => {
|
||||
const original = pbError(status);
|
||||
|
||||
const error = toDomainError(original);
|
||||
|
||||
expect(error).toBeInstanceOf(UnknownError);
|
||||
expect(error.cause).toBe(original);
|
||||
});
|
||||
|
||||
it.each([new Error('boom'), 'строка', undefined])(
|
||||
'произвольное значение (%s) → UnknownError с ним в cause',
|
||||
(original) => {
|
||||
const error = toDomainError(original);
|
||||
|
||||
expect(error).toBeInstanceOf(UnknownError);
|
||||
expect(error.cause).toBe(original);
|
||||
},
|
||||
);
|
||||
|
||||
it('уже доменную ошибку возвращает как есть', () => {
|
||||
const original = new NotFoundError();
|
||||
|
||||
expect(toDomainError(original)).toBe(original);
|
||||
});
|
||||
|
||||
it('любую доменную ошибку возвращает как есть, в том числе новую', () => {
|
||||
class ConflictError extends DomainError {}
|
||||
const original = new ConflictError();
|
||||
|
||||
expect(toDomainError(original)).toBe(original);
|
||||
});
|
||||
|
||||
it('400 с полями без строкового code → UnknownError', () => {
|
||||
const error = toDomainError(pbError(400, { email: { message: 'Нет кода' }, name: null }));
|
||||
|
||||
expect(error).toBeInstanceOf(UnknownError);
|
||||
});
|
||||
|
||||
it('сохраняет исходную ошибку в cause у NotFound, Unauthorized и Network', () => {
|
||||
const notFound = pbError(404);
|
||||
const unauthorized = pbError(401);
|
||||
const network = new ClientResponseError({ status: 0 });
|
||||
|
||||
expect(toDomainError(notFound).cause).toBe(notFound);
|
||||
expect(toDomainError(unauthorized).cause).toBe(unauthorized);
|
||||
expect(toDomainError(network).cause).toBe(network);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
import { ClientResponseError } from 'pocketbase';
|
||||
import {
|
||||
DomainError,
|
||||
NetworkError,
|
||||
NotFoundError,
|
||||
UnauthorizedError,
|
||||
UnknownError,
|
||||
ValidationError,
|
||||
} from './domain-errors';
|
||||
|
||||
/** Переводит ошибку PocketBase SDK (`ClientResponseError`) в доменную ошибку из `domain-errors.ts`. */
|
||||
export function toDomainError(error: unknown): Error {
|
||||
if (error instanceof DomainError) {
|
||||
return error;
|
||||
}
|
||||
if (!(error instanceof ClientResponseError)) {
|
||||
return new UnknownError(undefined, { cause: error });
|
||||
}
|
||||
if (error.isAbort || error.status === 0) {
|
||||
return new NetworkError(undefined, { cause: error });
|
||||
}
|
||||
if (error.status === 404) {
|
||||
return new NotFoundError(undefined, { cause: error });
|
||||
}
|
||||
if (error.status === 401 || error.status === 403) {
|
||||
return new UnauthorizedError(undefined, { cause: error });
|
||||
}
|
||||
if (error.status === 400) {
|
||||
const fieldErrors = toFieldErrors(error.response['data']);
|
||||
if (Object.keys(fieldErrors).length > 0) {
|
||||
return new ValidationError(fieldErrors, { cause: error });
|
||||
}
|
||||
}
|
||||
return new UnknownError(undefined, { cause: error });
|
||||
}
|
||||
|
||||
function toFieldErrors(data: unknown): Record<string, string> {
|
||||
const fieldErrors: Record<string, string> = {};
|
||||
if (typeof data !== 'object' || data === null) {
|
||||
return fieldErrors;
|
||||
}
|
||||
for (const [field, value] of Object.entries(data)) {
|
||||
const code = (value as { code?: unknown } | null)?.code;
|
||||
if (typeof code === 'string') {
|
||||
fieldErrors[field] = code;
|
||||
}
|
||||
}
|
||||
return fieldErrors;
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { InjectionToken, Provider } from '@angular/core';
|
||||
|
||||
/** Путь, по которому браузер обращается к PocketBase на своём origin (nginx / прокси dev-сервера). */
|
||||
export const PB_PUBLIC_PATH = '/pb';
|
||||
|
||||
/** Базовый адрес PocketBase: в браузере `<origin>/pb`, на сервере — `process.env.PB_URL`. */
|
||||
export const PB_URL = new InjectionToken<string>('PB_URL');
|
||||
|
||||
/** Серверный провайдер `PB_URL` (регистрируется в `app.config.server.ts`). */
|
||||
export function providePbUrlForServer(): Provider {
|
||||
return {
|
||||
provide: PB_URL,
|
||||
useFactory: () => process.env['PB_URL'] || 'http://127.0.0.1:8090',
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type PocketBase from 'pocketbase';
|
||||
import { inject } from 'vitest';
|
||||
import { createUser, type TestUser } from '../../../../tests/support/clients';
|
||||
import { PB_URL } from './pb-url';
|
||||
import { POCKETBASE, providePocketBase } from './pocketbase-client';
|
||||
|
||||
// Клиент из отдельного инжектора приложения, как у отдельного SSR-запроса.
|
||||
function pocketBaseFromDi(): PocketBase {
|
||||
return createEnvironmentInjector(
|
||||
[providePocketBase(), { provide: PB_URL, useValue: inject('pbUrl') }],
|
||||
TestBed.inject(EnvironmentInjector),
|
||||
).get(POCKETBASE);
|
||||
}
|
||||
|
||||
async function signedIn(user: TestUser): Promise<PocketBase> {
|
||||
const pb = pocketBaseFromDi();
|
||||
await pb.collection('users').authWithPassword(user.email, user.password);
|
||||
return pb;
|
||||
}
|
||||
|
||||
describe('клиент PocketBase из DI против тестового PocketBase', () => {
|
||||
it('получает health со статусом 200', async () => {
|
||||
const health = await pocketBaseFromDi().health.check();
|
||||
|
||||
expect(health.code).toBe(200);
|
||||
});
|
||||
|
||||
it('не отменяет параллельные запросы одного типа', async () => {
|
||||
const pb = pocketBaseFromDi();
|
||||
|
||||
const results = await Promise.all([pb.health.check(), pb.health.check()]);
|
||||
|
||||
expect(results.map((r) => r.code)).toEqual([200, 200]);
|
||||
});
|
||||
|
||||
it('клиенты двух пользователей и гостя не видят чужих записей', async () => {
|
||||
const [userA, userB] = await Promise.all([createUser(), createUser()]);
|
||||
const [pbA, pbB] = await Promise.all([signedIn(userA), signedIn(userB)]);
|
||||
const pbGuest = pocketBaseFromDi();
|
||||
|
||||
expect(pbA.authStore.record?.id).toBe(userA.id);
|
||||
expect(pbB.authStore.record?.id).toBe(userB.id);
|
||||
expect(pbA.authStore.token).not.toBe(pbB.authStore.token);
|
||||
expect(pbGuest.authStore.isValid).toBe(false);
|
||||
|
||||
await expect(pbA.collection('users').getOne(userA.id)).resolves.toMatchObject({
|
||||
id: userA.id,
|
||||
});
|
||||
await expect(pbB.collection('users').getOne(userB.id)).resolves.toMatchObject({
|
||||
id: userB.id,
|
||||
});
|
||||
await expect(pbA.collection('users').getOne(userB.id)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
await expect(pbB.collection('users').getOne(userA.id)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
await expect(pbGuest.collection('users').getOne(userA.id)).rejects.toMatchObject({
|
||||
status: 404,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { DOCUMENT, EnvironmentInjector, createEnvironmentInjector } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { LocalAuthStore } from 'pocketbase';
|
||||
import { PB_PUBLIC_PATH, PB_URL, providePbUrlForServer } from './pb-url';
|
||||
import { POCKETBASE, providePocketBase } from './pocketbase-client';
|
||||
|
||||
function createAppInjector(): EnvironmentInjector {
|
||||
return createEnvironmentInjector(
|
||||
[providePocketBase(), { provide: PB_URL, useValue: 'http://pb.test:1234' }],
|
||||
TestBed.inject(EnvironmentInjector),
|
||||
);
|
||||
}
|
||||
|
||||
describe('клиент PocketBase в DI', () => {
|
||||
it('в одном инжекторе отдаёт один и тот же экземпляр', () => {
|
||||
TestBed.configureTestingModule({ providers: [providePocketBase()] });
|
||||
|
||||
expect(TestBed.inject(POCKETBASE)).toBe(TestBed.inject(POCKETBASE));
|
||||
});
|
||||
|
||||
it('в двух инжекторах отдаёт разные экземпляры с независимым authStore', () => {
|
||||
const a = createAppInjector().get(POCKETBASE);
|
||||
const b = createAppInjector().get(POCKETBASE);
|
||||
|
||||
a.authStore.save('token-a', null);
|
||||
|
||||
expect(a).not.toBe(b);
|
||||
expect(b.authStore.token).toBe('');
|
||||
expect(b.authStore.isValid).toBe(false);
|
||||
});
|
||||
|
||||
it('берёт baseURL из PB_URL', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [providePocketBase(), { provide: PB_URL, useValue: 'http://pb.test:1234' }],
|
||||
});
|
||||
|
||||
expect(TestBed.inject(POCKETBASE).baseURL).toBe('http://pb.test:1234');
|
||||
});
|
||||
|
||||
it('хранит авторизацию в памяти, а не в localStorage', () => {
|
||||
TestBed.configureTestingModule({ providers: [providePocketBase()] });
|
||||
const pb = TestBed.inject(POCKETBASE);
|
||||
const keysBefore = Object.keys(localStorage);
|
||||
|
||||
pb.authStore.save('token', null);
|
||||
|
||||
expect(pb.authStore).not.toBeInstanceOf(LocalAuthStore);
|
||||
expect(Object.keys(localStorage)).toEqual(keysBefore);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PB_URL', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it('PB_PUBLIC_PATH равен /pb', () => {
|
||||
expect(PB_PUBLIC_PATH).toBe('/pb');
|
||||
});
|
||||
|
||||
it('в браузере — <origin>/pb', () => {
|
||||
TestBed.configureTestingModule({ providers: [providePocketBase()] });
|
||||
|
||||
expect(TestBed.inject(PB_URL)).toBe(`${TestBed.inject(DOCUMENT).location.origin}/pb`);
|
||||
});
|
||||
|
||||
it('на сервере — значение переменной окружения PB_URL', () => {
|
||||
vi.stubEnv('PB_URL', 'http://pb.internal:9000');
|
||||
TestBed.configureTestingModule({ providers: [providePbUrlForServer()] });
|
||||
|
||||
expect(TestBed.inject(PB_URL)).toBe('http://pb.internal:9000');
|
||||
});
|
||||
|
||||
it('на сервере без PB_URL — http://127.0.0.1:8090', () => {
|
||||
vi.stubEnv('PB_URL', undefined);
|
||||
TestBed.configureTestingModule({ providers: [providePbUrlForServer()] });
|
||||
|
||||
expect(TestBed.inject(PB_URL)).toBe('http://127.0.0.1:8090');
|
||||
});
|
||||
|
||||
it('на сервере с пустым PB_URL — http://127.0.0.1:8090', () => {
|
||||
vi.stubEnv('PB_URL', '');
|
||||
TestBed.configureTestingModule({ providers: [providePbUrlForServer()] });
|
||||
|
||||
expect(TestBed.inject(PB_URL)).toBe('http://127.0.0.1:8090');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import {
|
||||
DOCUMENT,
|
||||
EnvironmentProviders,
|
||||
InjectionToken,
|
||||
inject,
|
||||
makeEnvironmentProviders,
|
||||
} from '@angular/core';
|
||||
import PocketBase, { BaseAuthStore } from 'pocketbase';
|
||||
import { PB_PUBLIC_PATH, PB_URL } from './pb-url';
|
||||
|
||||
/** Клиент PocketBase: свой экземпляр на каждый инжектор приложения (на сервере — на каждый запрос). */
|
||||
export const POCKETBASE = new InjectionToken<PocketBase>('POCKETBASE');
|
||||
|
||||
/** Регистрирует `PB_URL` (браузерный) и `POCKETBASE`; вызывается в `app.config.ts`. Только для инжектора приложения, не для `providers` компонента. */
|
||||
export function providePocketBase(): EnvironmentProviders {
|
||||
return makeEnvironmentProviders([
|
||||
{
|
||||
provide: PB_URL,
|
||||
useFactory: () => inject(DOCUMENT).location.origin + PB_PUBLIC_PATH,
|
||||
},
|
||||
{
|
||||
provide: POCKETBASE,
|
||||
useFactory: () =>
|
||||
new PocketBase(inject(PB_URL), new BaseAuthStore()).autoCancellation(false),
|
||||
},
|
||||
]);
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Временный дымовой тест стенда интеграционных тестов; удалить, когда появятся настоящие *.integration.spec.ts.
|
||||
import { InjectionToken, Injector } from '@angular/core';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import type PocketBase from 'pocketbase';
|
||||
import { inject } from 'vitest';
|
||||
import { asGuest, asSuperuser, asUser, createUser } from '../../tests/support/clients';
|
||||
|
||||
const PB = new InjectionToken<PocketBase>('PB');
|
||||
|
||||
describe('стенд интеграционных тестов', () => {
|
||||
it('URL тестового PocketBase доступен и GET /api/health отвечает 200', async () => {
|
||||
const response = await fetch(`${inject('pbUrl')}/api/health`);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
});
|
||||
|
||||
it('TestBed.inject() работает', () => {
|
||||
const GREETING = new InjectionToken<string>('GREETING');
|
||||
TestBed.configureTestingModule({ providers: [{ provide: GREETING, useValue: 'привет' }] });
|
||||
|
||||
expect(TestBed.inject(Injector).get(GREETING)).toBe('привет');
|
||||
});
|
||||
|
||||
it('createUser() создаёт пользователя на тестовом PocketBase', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const record = await (await asSuperuser()).collection('users').getOne(user.id);
|
||||
|
||||
expect(record['email']).toBe(user.email);
|
||||
});
|
||||
|
||||
it('asUser(user) авторизован именно этим пользователем', async () => {
|
||||
const user = await createUser();
|
||||
|
||||
const pb = await asUser(user);
|
||||
|
||||
expect(pb.authStore.record?.id).toBe(user.id);
|
||||
});
|
||||
|
||||
it('клиент PocketBase, полученный через DI, обращается к тестовому PocketBase', async () => {
|
||||
TestBed.configureTestingModule({ providers: [{ provide: PB, useFactory: asGuest }] });
|
||||
|
||||
const health = await TestBed.inject(PB).health.check();
|
||||
|
||||
expect(health.code).toBe(200);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user