From d0d84b8e8e11b80885236df92748698e4e696a9d Mon Sep 17 00:00:00 2001 From: DarKylX Date: Mon, 21 Sep 2026 23:03:31 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D0=B7=D0=B0=D1=89=D0=B8=D1=82=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D0=B4=D0=BE=D1=81=D1=82=D1=83=D0=BF=20=D0=BA?= =?UTF-8?q?=D0=B0=D1=80=D1=82=D0=BE=D1=87=D0=BA=D0=B8=20=D0=BF=D1=80=D0=B8?= =?UTF-8?q?=20=D0=BD=D0=B5=D0=B8=D0=B7=D0=B2=D0=B5=D1=81=D1=82=D0=BD=D0=BE?= =?UTF-8?q?=D0=BC=20=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D0=B5=20=D1=81?= =?UTF-8?q?=D0=B4=D0=B0=D1=87=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/project-card-list-contract.md | 45 ++++++ .../info-card/info-card-list-contract.spec.ts | 136 ++++++++++++++++++ .../widgets/info-card/info-card.component.ts | 7 +- 3 files changed, 186 insertions(+), 2 deletions(-) create mode 100644 docs/project-card-list-contract.md create mode 100644 projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts diff --git a/docs/project-card-list-contract.md b/docs/project-card-list-contract.md new file mode 100644 index 000000000..f71da3ccf --- /dev/null +++ b/docs/project-card-list-contract.md @@ -0,0 +1,45 @@ + + +# Неполный 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. diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts new file mode 100644 index 000000000..dfe6d84e8 --- /dev/null +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card-list-contract.spec.ts @@ -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; + 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 { + 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 ? "можно редактировать" : "только просмотр", + ); + } + }); +}); diff --git a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts index 83156248c..15007d28a 100644 --- a/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts +++ b/projects/social_platform/src/app/ui/widgets/info-card/info-card.component.ts @@ -96,7 +96,9 @@ export class InfoCardComponent { * Роль определяется отдельно по текущему профилю. Отсутствующие ID не делают * пользователя лидером; после сдачи даже лидер видит «только просмотр». * Единственный признак сдачи — isSubmitted: canSubmit описывает возможность - * действия, например открытый срок. Это представление готовых данных, + * действия, например открытый срок. Пока состояние программной связи неизвестно, + * не обещаем редактирование: отсутствие isSubmitted не равнозначно false. + * Это представление готовых данных, * а не изменение guard или серверных прав. CTA от этих значений не зависит. */ protected readonly myProjectPresentation = computed(() => { @@ -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],