Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions docs/project-card-list-contract.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
<!-- @format -->

# Неполный lifecycle в карточке «Моих проектов»

## Источник и безопасное поведение

`/auth/users/projects/` ранее возвращал `partner_program` только с `id/name`.
Отсутствующий `isSubmitted` ошибочно разрешал подпись «можно редактировать».
Backend расширяет компактный контракт до `id`, `name`, `program_link_id`,
`program_id`, `is_submitted`. Выбранная связь с минимальным PK совпадает с detail.

Карточка разрешает подпись о редактировании только когда пользователь — лидер
и либо связи нет, либо её `isSubmitted === false`. Связь без достоверного состояния
сдачи означает «только просмотр», в том числе для черновика.

Lifecycle остаётся независимым от роли: `submitted > draft > program > published`.
При неизвестном состоянии связи сохраняется нейтральный статус «В программе»
(или «Черновик» при `draft=true`). `canSubmit` не используется для определения сдачи.
Неизвестный пользователь не считается лидером.

CTA всегда «Открыть» и ведёт в `AppRoutes.projects.detail(id)`. Шаблон, стили,
размеры, аватар, подписки, списки all/subs, detail guard и редактор не изменены.
Это отображение данных; серверные права не меняются.

## Интеграционная регрессия

`info-card-list-contract.spec.ts` подаёт сокращённый snake_case JSON через
`HttpTestingController`, настоящий `CamelcaseInterceptor`, `ApiService`,
`ProjectHttpAdapter`, `ProjectRepository` и `GetMyProjectsUseCase` в `InfoCardComponent`.
Маппинг не подменяется готовой camelCase-фикстурой.

Проверяются true/false/отсутствие `is_submitted`, draft, смена лидера/участника/
неизвестного профиля и последовательные ответы с известным/неизвестным состоянием.
Для сданного проекта результат — «Сдан в программу / Лидер / только просмотр».
Существующая матрица карточек, List и Dashboard сохраняется.

## Будущая DEV-приёмка

Backend-контракт нужно развернуть перед Angular. На старом backend защита уже
убирает обещание редактирования, но определить факт сдачи без данных не может.
После отдельного разрешения на merge/deploy сравнить list/detail одного сданного
DEV-проекта: `program_link_id` и `is_submitted` должны совпасть; карточка и detail
должны одинаково показывать недоступность редактирования.

Эта live-проверка сейчас не выполнена: задача завершается двумя Draft PR без deploy.
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/** @format */

import { HTTP_INTERCEPTORS, provideHttpClient, withInterceptorsFromDi } from "@angular/common/http";
import { HttpTestingController, provideHttpClientTesting } from "@angular/common/http/testing";
import { ComponentFixture, TestBed } from "@angular/core/testing";
import { By } from "@angular/platform-browser";
import { provideRouter, Router, RouterLink } from "@angular/router";
import { firstValueFrom, of } from "rxjs";
import { API_URL, CamelcaseInterceptor } from "@corelib";
import { GetMyProjectsUseCase } from "@api/project/use-cases/get-my-projects.use-case";
import { AddProjectSubscriptionUseCase } from "@api/project/use-cases/add-project-subscription.use-case";
import { DeleteProjectSubscriptionUseCase } from "@api/project/use-cases/delete-project-subscription.use-case";
import { AppRoutes } from "@api/paths/app-routes";
import { IndustryRepositoryPort } from "@domain/industry/ports/industry.repository.port";
import { ProjectRepositoryPort } from "@domain/project/ports/project.repository.port";
import { Project } from "@domain/project/project.model";
import { ok } from "@domain/shared/result.type";
import { ProjectRepository } from "@infrastructure/repository/project/project.repository";
import { InfoCardComponent } from "./info-card.component";

describe("Мои проекты: HTTP list → преобразование контракта → карточка", () => {
let fixture: ComponentFixture<InfoCardComponent>;
let http: HttpTestingController;

beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [InfoCardComponent],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
{ provide: HTTP_INTERCEPTORS, useClass: CamelcaseInterceptor, multi: true },
{ provide: API_URL, useValue: "/api" },
{ provide: ProjectRepositoryPort, useClass: ProjectRepository },
{ provide: IndustryRepositoryPort, useValue: { getOne: () => undefined } },
{ provide: AddProjectSubscriptionUseCase, useValue: { execute: () => of(ok(undefined)) } },
{
provide: DeleteProjectSubscriptionUseCase,
useValue: { execute: () => of(ok(undefined)) },
},
provideRouter([]),
],
}).compileComponents();
http = TestBed.inject(HttpTestingController);
fixture = TestBed.createComponent(InfoCardComponent);
fixture.componentRef.setInput("appereance", "my");
fixture.componentRef.setInput("loggedUserId", 7);
});

afterEach(() => http.verify());

/**
* Передаёт настоящий сокращённый snake_case payload через HTTP/interceptor,
* адаптер, репозиторий и use case. Готовая camelCase-фикстура скрыла бы дефект.
*/
async function loadProject(submitted: boolean | undefined, draft = false): Promise<void> {
const resultPromise = firstValueFrom(TestBed.inject(GetMyProjectsUseCase).execute());
const request = http.expectOne("/api/auth/users/projects/");
expect(request.request.method).toBe("GET");
request.flush({
count: 1,
next: null,
previous: null,
results: [
{
id: 101,
name: "Проект программы",
leader: 7,
short_description: "Описание проекта",
image_address: "/assets/images/projects/shared/idea.svg",
industry: 1,
views_count: 0,
draft,
is_company: false,
partner_program: {
id: 12,
name: "Программа",
...(submitted === undefined
? {}
: { program_link_id: 120, program_id: 12, is_submitted: submitted }),
},
},
],
});
const result = await resultPromise;
expect(result.ok).toBe(true);
if (!result.ok) throw new Error("Список проектов не загружен");
const project = result.value.results[0];
expect(project).toBeInstanceOf(Project);
expect(project.shortDescription).toBe("Описание проекта");
expect(project.partnerProgram?.isSubmitted).toBe(submitted);
if (submitted !== undefined) {
expect(project.partnerProgram?.programLinkId).toBe(120);
expect(project.partnerProgram?.programId).toBe(12);
}
fixture.componentRef.setInput("info", project);
fixture.detectChanges();
}

const text = (selector: string): string =>
fixture.nativeElement.querySelector(selector)?.textContent?.trim();

it.each([
{ submitted: true, draft: false, status: "Сдан в программу" },
{ submitted: true, draft: true, status: "Сдан в программу" },
{ submitted: false, draft: false, status: "В программе" },
{ submitted: false, draft: true, status: "Черновик" },
{ submitted: undefined, draft: false, status: "В программе" },
{ submitted: undefined, draft: true, status: "Черновик" },
])("is_submitted=$submitted, draft=$draft: lifecycle и доступ независимы", async item => {
await loadProject(item.submitted, item.draft);
for (const userId of [7, 99, undefined]) {
fixture.componentRef.setInput("loggedUserId", userId);
fixture.detectChanges();
expect(text(".card__status")).toBe(item.status);
expect(text(".card__role")).toBe(userId === 7 ? "Лидер" : "Участник");
expect(text(".card__access-label")).toBe(
userId === 7 && item.submitted === false ? "можно редактировать" : "только просмотр",
);
expect(text(".card__project-action")).toBe("Открыть");
const link = fixture.debugElement.query(By.directive(RouterLink)).injector.get(RouterLink);
expect(TestBed.inject(Router).serializeUrl(link.urlTree!)).toBe(
AppRoutes.projects.detail(101),
);
expect(link.urlTree!.queryParams).toEqual({});
}
});

it("обновляет доступ после получения состояния и не сохраняет его для неполного ответа", async () => {
for (const submitted of [undefined, false, true, undefined]) {
await loadProject(submitted);
expect(text(".card__access-label")).toBe(
submitted === false ? "можно редактировать" : "только просмотр",
);
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,9 @@ export class InfoCardComponent {
* Роль определяется отдельно по текущему профилю. Отсутствующие ID не делают
* пользователя лидером; после сдачи даже лидер видит «только просмотр».
* Единственный признак сдачи — isSubmitted: canSubmit описывает возможность
* действия, например открытый срок. Это представление готовых данных,
* действия, например открытый срок. Пока состояние программной связи неизвестно,
* не обещаем редактирование: отсутствие isSubmitted не равнозначно false.
* Это представление готовых данных,
* а не изменение guard или серверных прав. CTA от этих значений не зависит.
*/
protected readonly myProjectPresentation = computed<MyProjectPresentation | null>(() => {
Expand All @@ -121,7 +123,8 @@ export class InfoCardComponent {
};
const userId = this.loggedUserId();
const isLeader = userId != null && project.leader === userId;
const canEdit = isLeader && !isSubmitted;
const canEdit =
isLeader && (project.partnerProgram == null || project.partnerProgram.isSubmitted === false);
return {
lifecycle,
statusLabel: labels[lifecycle],
Expand Down