Angular IDPStrong Junior
Модулі / Дані, моки й RxJS / Мок-шар за контрактом
🔥 0

Модуль 4 · урок 11 з 13

Мок-шар за контрактом

Це серце четвертої цілі: «mock incoming data strictly at the Angular service layer adhering to Swagger REST contracts». Тобто дані вигадані, але межа справжня — коли зʼявиться бекенд, ти заміниш нутрощі одного сервісу, і застосунок цього не помітить.

Крок 1: описати контракт

Спочатку — не код, а домовленість. Випиши ендпоїнти так, ніби їх уже реалізує бекенд. Це може бути просто файл у репозиторії:

GET    /api/tasks?page=&size=&sort=&q=&status=   → { items: TaskDto[]; total: number }
GET    /api/tasks/{id}                          → TaskDto
POST   /api/tasks                               → TaskDto        (409, якщо code зайнятий)
PATCH  /api/tasks/{id}                          → TaskDto
DELETE /api/tasks/{id}                          → 204
GET    /api/tasks/code-taken?code=              → { taken: boolean }
GET    /api/users                               → UserDto[]

Саме цей список ти покажеш ліду й спитаєш: «чи так у вас виглядають контракти?» Питання добре тим, що воно про роботу, а не про навчання.

Крок 2: інтерфейс сервісу

export interface TaskApi {
  list(params: TaskQuery): Observable<Page<Task>>;
  byId(id: string): Observable<Task>;
  create(payload: NewTask): Observable<Task>;
  patch(id: string, patch: Partial<Task>): Observable<Task>;
  remove(id: string): Observable<void>;
  isCodeTaken(code: string): Observable<boolean>;
}

export interface Page<T> {
  items: readonly T[];
  total: number;
}

Тут одразу працюють generics і utility types із модуля 2. Реалізацій буде дві — мок і справжня, — а решта застосунку знає лише інтерфейс.

Крок 3: генератор даних

const CODES = ['KYIV', 'NOVA', 'DNPR', 'LVIV'];
const STATUSES: TaskStatus[] = ['todo', 'in_progress', 'blocked', 'done'];

export function makeTasks(count = 800): Task[] {
  return Array.from({ length: count }, (_, i) => ({
    id: `t-${i + 1}`,
    code: `${CODES[i % CODES.length]}-${100 + i}`,
    title: `Задача №${i + 1}`,
    status: STATUSES[i % STATUSES.length],
    assigneeId: i % 5 === 0 ? null : `u-${(i % 18) + 1}`,
    estimateHours: 2 + (i % 12),
    dueDate: new Date(Date.now() + (i % 30 - 10) * 86_400_000).toISOString(),
  }));
}
Чому саме 800 рядків

На двадцяти рядках усе працює швидко, і жодна проблема з модуля 3 не видно. Кількасот рядків — це поріг, за яким відчувається різниця між OnPush і без нього, між track і без нього, і за яким пагінація перестає бути формальністю. Мок має бути схожим на реальні дані обсягом, інакше він нічого не перевіряє.

Крок 4: мок-реалізація

@Injectable({ providedIn: 'root' })
export class TaskApiMock implements TaskApi {
  private tasks: Task[] = makeTasks();
  private failEvery = 7;      // кожен сьомий запит навмисно падає
  private calls = 0;

  list(params: TaskQuery): Observable<Page<Task>> {
    return this.simulate(() => {
      let rows = this.tasks;

      if (params.status && params.status !== 'all') {
        rows = rows.filter(t => t.status === params.status);
      }
      if (params.q) {
        const q = params.q.toLowerCase();
        rows = rows.filter(t => t.title.toLowerCase().includes(q) || t.code.toLowerCase().includes(q));
      }
      if (params.sort) {
        rows = [...rows].sort(compareBy(params.sort));
      }

      const start = (params.page - 1) * params.size;
      return { items: rows.slice(start, start + params.size), total: rows.length };
    });
  }

  create(payload: NewTask): Observable<Task> {
    if (this.tasks.some(t => t.code === payload.code)) {
      return throwError(() => new HttpErrorResponse({ status: 409, statusText: 'Conflict' }));
    }
    const task: Task = { ...payload, id: `t-${Date.now()}` };
    this.tasks = [task, ...this.tasks];
    return this.simulate(() => task);
  }

  isCodeTaken(code: string): Observable<boolean> {
    return this.simulate(() => this.tasks.some(t => t.code === code), 250);
  }

  // одна обгортка на всі методи: затримка й випадкові збої
  private simulate<T>(fn: () => T, ms = 300 + Math.random() * 500): Observable<T> {
    this.calls++;
    if (this.calls % this.failEvery === 0) {
      return timer(ms).pipe(
        switchMap(() => throwError(() => new HttpErrorResponse({ status: 500 }))),
      );
    }
    return of(fn()).pipe(delay(ms));
  }
}

Три речі, які роблять цей мок корисним, а не декоративним:

  • Затримка. Без неї не побачити скелетонів і не перевірити, що кнопка блокується під час збереження.
  • Керовані збої. Кожен сьомий виклик падає — і ти змушений написати обробку помилок по-справжньому, а не «колись потім».
  • Стан живе між викликами. Створив задачу — вона зʼявляється у списку. Інакше CRUD із модуля 6 буде неможливо перевірити.

Крок 5: підміна одним рядком

// app.config.ts
providers: [
  { provide: TASK_API, useClass: TaskApiMock },      // зараз
  // { provide: TASK_API, useClass: TaskApiHttp },   // коли зʼявиться бекенд
]

// core/api/task-api.token.ts
export const TASK_API = new InjectionToken<TaskApi>('TaskApi');

// у сторі
private readonly api = inject(TASK_API);
Компоненти і TaskStore ІНТЕРФЕЙС TASKAPI межа, яка не міняється TaskApiMock of + delay + збої TaskApiHttp справжній бекенд Заміна реалізації — це один рядок у app.config.ts. Ні компоненти, ні стор, ні шаблони не змінюються взагалі. Саме це й називають архітектурою.
Мок і справжній API — дві реалізації одного інтерфейсу. Решта застосунку залежить від контракту, а не від джерела даних.
Бонус: те саме працює в тестах

У модулі 8 тести стору отримають третю реалізацію — з передбачуваними даними й без затримок. Оскільки залежність від токена, підміна робиться одним рядком у TestBed. Тобто зусилля, вкладені зараз, повернуться двічі.