From 43c1f830df1d21bae53f3b61cc091146771ea508 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Mon, 26 Oct 2026 01:09:13 +0400 Subject: [PATCH] =?UTF-8?q?=D0=92=D0=BB=D0=BE=D0=B6=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D1=8F:=20=D0=B1=D0=B5=D0=B7=20=D0=BF=D1=80=D0=B5=D0=B6=D0=BD?= =?UTF-8?q?=D0=B5=D0=B9=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B8=20=D0=BF?= =?UTF-8?q?=D0=BE=D1=81=D0=BB=D0=B5=20=D0=BF=D0=BE=D0=B2=D1=82=D0=BE=D1=80?= =?UTF-8?q?=D0=B0,=20401=20=E2=80=94=20=D1=8D=D0=BA=D1=80=D0=B0=D0=BD=20?= =?UTF-8?q?=D0=B2=D1=85=D0=BE=D0=B4=D0=B0,=20=D0=BF=D0=BE=D1=80=D1=8F?= =?UTF-8?q?=D0=B4=D0=BE=D0=BA=20=D1=85=D0=B0=D0=B1=D0=B0=20(#124,=20#86=20?= =?UTF-8?q?T6.2,=20T6.3,=20M-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../modules/jira/issues/IssueCard.test.tsx | 43 +++++++++++++++---- .../issues/attachments/AttachmentList.tsx | 12 ++---- .../useAttachmentDownload.test.tsx | 39 ++++++++++++----- .../attachments/useAttachmentDownload.ts | 21 +++++---- .../modules/jira/issues/wiki/inline.test.ts | 8 +++- web/src/modules/jira/issues/wiki/inline.ts | 11 +---- 6 files changed, 88 insertions(+), 46 deletions(-) diff --git a/web/src/modules/jira/issues/IssueCard.test.tsx b/web/src/modules/jira/issues/IssueCard.test.tsx index 0bfb060..0b90fbe 100644 --- a/web/src/modules/jira/issues/IssueCard.test.tsx +++ b/web/src/modules/jira/issues/IssueCard.test.tsx @@ -524,7 +524,7 @@ describe("вложения в карточке", () => { return mockFetch((m, url) => extra?.(m, url) ?? (url.pathname === "/settings" ? json(200, settings) : json(200, issue("AURORA-1650", { ...base, ...over })))); } - it("раздел «Вложения (2)» после «Описание», новые сверху; размер, автор · дата", async () => { + it("раздел «Вложения (2)» после «Описание», порядок хаба; размер, автор · дата", async () => { hub({}); open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); @@ -532,12 +532,39 @@ describe("вложения в карточке", () => { const heads = screen.getAllByRole("heading", { level: 3 }).map((h) => h.textContent); expect(heads.indexOf("Вложения (2)")).toBe(heads.indexOf("Описание") + 1); const rows = sec.getAllByRole("listitem"); - expect(rows[0]).toHaveTextContent("ОСВ 60.xlsx"); - expect(rows[0]).toHaveTextContent("1,0 КБ"); - expect(rows[1]).toHaveTextContent("скрин 1.png"); - expect(rows[1]).toHaveTextContent("234,4 КБ"); - expect(rows[1]).toHaveTextContent("Иванов Иван · 15.10"); - expect(within(rows[1]).getByRole("button", { name: "Скачать" })).toBeInTheDocument(); + expect(rows[1]).toHaveTextContent("ОСВ 60.xlsx"); + expect(rows[1]).toHaveTextContent("1,0 КБ"); + expect(rows[0]).toHaveTextContent("скрин 1.png"); + expect(rows[0]).toHaveTextContent("234,4 КБ"); + expect(rows[0]).toHaveTextContent("Иванов Иван · 15.10"); + expect(within(rows[0]).getByRole("button", { name: "Скачать" })).toBeInTheDocument(); + }); + + it("порядок вложений — как у хаба, без пересортировки по дате", async () => { + hub({ attachments: [att("1", "b.bin", { created: null }), att("2", "a.bin", { created: "2026-10-20T10:00:00.000+0300" })], comments: [] }); + open("/jira/issues/AURORA-1650"); + const rows = within(await screen.findByTestId("attachments")).getAllByRole("listitem"); + expect(rows[0]).toHaveTextContent("b.bin"); + expect(rows[1]).toHaveTextContent("a.bin"); + }); + + it("повтор после ошибки: успех в карточке скрывает прежний failed из несвежего ответа хаба", async () => { + let fetched = false; + hub({ attachments: [att("31337", "скрин 1.png", { state: "failed", error: "сбой" })], comments: [] }, (_m, url) => { + if (url.pathname === "/attachments/31337/fetch") { fetched = true; return json(200, { attachment_id: "31337", state: "ready", error: null, command_id: 1, batch_id: 1 }); } + if (url.pathname === "/attachments/31337/content") return new Response("PNG", { status: 200 }); + return undefined; + }); + URL.createObjectURL = vi.fn(() => "blob:fake"); + URL.revokeObjectURL = vi.fn(); + vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => {}); + open("/jira/issues/AURORA-1650"); + const sec = within(await screen.findByTestId("attachments")); + expect(await sec.findByText("сбой")).toBeInTheDocument(); + await userEvent.click(sec.getByRole("button", { name: "Повторить" })); + await waitFor(() => expect(fetched).toBe(true)); + await waitFor(() => expect(sec.queryByText("сбой")).toBeNull()); + expect(sec.getByRole("button", { name: "Скачать" })).toBeInTheDocument(); }); it("без поля attachments (старый хаб) раздела нет", async () => { @@ -596,7 +623,7 @@ describe("вложения в карточке", () => { ? json(409, { error: "agent_too_old", detail: "агент v13 не умеет скачивать вложения — нужен v14" }) : undefined); open("/jira/issues/AURORA-1650"); const sec = within(await screen.findByTestId("attachments")); - await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[1]); + await userEvent.click(sec.getAllByRole("button", { name: "Скачать" })[0]); expect(await sec.findByText("агент v13 не умеет скачивать вложения — нужен v14")).toBeInTheDocument(); await userEvent.click(sec.getByRole("button", { name: "Повторить" })); expect(f.mock.calls.filter(([u]) => String(u).endsWith("/fetch"))).toHaveLength(2); diff --git a/web/src/modules/jira/issues/attachments/AttachmentList.tsx b/web/src/modules/jira/issues/attachments/AttachmentList.tsx index 845c9ad..066b091 100644 --- a/web/src/modules/jira/issues/attachments/AttachmentList.tsx +++ b/web/src/modules/jira/issues/attachments/AttachmentList.tsx @@ -10,19 +10,13 @@ export type Download = ReturnType; const KIND_ICON = { image: IconImage, sheet: IconSheet, pdf: IconFile, other: IconFile }; const KIND_LABEL = { image: "картинка", sheet: "таблица", pdf: "PDF", other: "файл" }; -// новые сверху: по времени Jira, затем по числовому id -export function newestFirst(items: IssueAttachment[]): IssueAttachment[] { - const t = (a: IssueAttachment) => (a.created ? Date.parse(a.created) || 0 : 0); - return [...items].sort((a, b) => t(b) - t(a) || (Number(b.attachment_id) || 0) - (Number(a.attachment_id) || 0)); -} - // раздел «Вложения (N)»: значок по типу, имя, размер, «автор · дата», отметка «у хаба», кнопка скачивания export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Download }) { return (

Вложения ({items.length})

    - {newestFirst(items).map((a) => )} + {items.map((a) => )}
); @@ -31,9 +25,9 @@ export function AttachmentList({ items, dl }: { items: IssueAttachment[]; dl: Do function Row({ a, dl }: { a: IssueAttachment; dl: Download }) { const kind = fileKind(a.mime_type, a.filename); const Icon = KIND_ICON[kind]; - const { busy, error: local } = dl.status(a.attachment_id); + const { busy, error: local, ok } = dl.status(a.attachment_id); // ошибка: своя (последняя попытка в этой карточке) или команда, закончившаяся failed до открытия карточки - const error = busy ? null : local ?? (a.state === "failed" ? a.error ?? "не удалось скачать вложение" : null); + const error = busy ? null : local ?? (a.state === "failed" && !ok ? a.error ?? "не удалось скачать вложение" : null); const who = [a.author, a.created ? fmtStamp(a.created) : null].filter(Boolean).join(" · "); return (
  • diff --git a/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx b/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx index 9c31694..4abee3d 100644 --- a/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx +++ b/web/src/modules/jira/issues/attachments/useAttachmentDownload.test.tsx @@ -4,7 +4,7 @@ import type { ReactNode } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { makeQueryClient } from "../../../../api/queryClient.ts"; import type { IssueAttachment } from "../../api/types.ts"; -import { setToken } from "../../../../auth/token.ts"; +import { getToken, setToken } from "../../../../auth/token.ts"; import { json, mockFetch } from "../../../../test/fetchMock.ts"; import { useAttachmentDownload } from "./useAttachmentDownload.ts"; @@ -38,7 +38,7 @@ describe("useAttachmentDownload", () => { const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); act(() => result.current.start(att)); await flush(); - expect(result.current.status("31337")).toEqual({ busy: true, error: null }); + expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false }); expect(JSON.parse(String(f.mock.calls[0][1]?.body))).toEqual({ author: "web" }); await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); expect(polls).toBe(1); @@ -48,7 +48,7 @@ describe("useAttachmentDownload", () => { expect(URL.revokeObjectURL).not.toHaveBeenCalled(); // I-2: отзыв отложен await act(async () => { await vi.advanceTimersByTimeAsync(60_000); }); expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:fake"); - expect(result.current.status("31337")).toEqual({ busy: false, error: null }); + expect(result.current.status("31337")).toEqual({ busy: false, error: null, ok: true }); await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); expect(polls).toBe(2); expect(f.mock.calls.filter(([u]) => String(u).endsWith("/content"))).toHaveLength(1); @@ -84,7 +84,7 @@ describe("useAttachmentDownload", () => { const n = f.mock.calls.length; await act(async () => { await vi.advanceTimersByTimeAsync(10000); }); expect(f.mock.calls.length).toBe(n); - expect(result.current.status("31337")).toEqual({ busy: false, error: null }); + expect(result.current.status("31337")).toEqual({ busy: false, error: null, ok: false }); }); it("409 — ошибка с текстом хаба", async () => { @@ -92,20 +92,39 @@ describe("useAttachmentDownload", () => { const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); act(() => result.current.start(att)); await flush(); - expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14" }); + expect(result.current.status("31337")).toEqual({ busy: false, error: "агент v13 не умеет скачивать вложения — нужен v14", ok: false }); expect(clicked).toHaveLength(0); }); - it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => { - let n = 0; - mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404" }) : st("fetching"))); + it("401 при опросе — сброс токена, без текста ошибки", async () => { + mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(401, { error: "unauthorized" })); const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); act(() => result.current.start(att)); await flush(); await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); - expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404" }); + expect(getToken()).toBe(""); + expect(result.current.status("31337").error).toBeNull(); + }); + + it("сеть недоступна при опросе — «хаб не отвечает»", async () => { + mockFetch((m) => { if (m === "POST") return json(200, st("fetching")); throw new TypeError("fetch failed"); }); + const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); act(() => result.current.start(att)); await flush(); - expect(result.current.status("31337")).toEqual({ busy: true, error: null }); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(result.current.status("31337").error).toBe("хаб не отвечает"); + }); + + it("failed при опросе — ошибка команды; повторное start сбрасывает ошибку", async () => { + let n = 0; + mockFetch((m) => m === "POST" ? json(200, st("fetching")) : json(200, ++n === 1 ? st("failed", { error: "Jira: 404", ok: false }) : st("fetching"))); + const { result } = renderHook(() => useAttachmentDownload("AURORA-1650"), { wrapper }); + act(() => result.current.start(att)); + await flush(); + await act(async () => { await vi.advanceTimersByTimeAsync(2000); }); + expect(result.current.status("31337")).toEqual({ busy: false, error: "Jira: 404", ok: false }); + act(() => result.current.start(att)); + await flush(); + expect(result.current.status("31337")).toEqual({ busy: true, error: null, ok: false }); }); }); diff --git a/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts b/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts index e56b230..508d3e0 100644 --- a/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts +++ b/web/src/modules/jira/issues/attachments/useAttachmentDownload.ts @@ -1,13 +1,13 @@ import { useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useRef, useState } from "react"; -import { ApiError } from "../../../../api/client.ts"; +import { ApiError, AuthError, dropTokenOn, HubDownError } from "../../../../api/client.ts"; import { api } from "../../../../api/client.ts"; import { qk } from "../../api/queries.ts"; import type { AttachmentState, IssueAttachment } from "../../api/types.ts"; export const POLL_MS = 2000; -export interface DownloadStatus { busy: boolean; error: string | null } -const IDLE: DownloadStatus = { busy: false, error: null }; +export interface DownloadStatus { busy: boolean; error: string | null; ok: boolean } // ok — успех в этой карточке +const IDLE: DownloadStatus = { busy: false, error: null, ok: false }; // браузер сохраняет файл под своим именем: Blob → временная ссылка download (токен в адресе не появляется) function save(blob: Blob, filename: string) { @@ -56,7 +56,12 @@ export function useAttachmentDownload(issueKey: string) { if (mapRef.current[id]?.busy) return; const my = gen.current; const alive = () => gen.current === my; - const fail = (error: string) => { if (alive()) put(id, { busy: false, error }); }; + const fail = (error: string) => { if (alive()) put(id, { busy: false, error, ok: false }); }; + // 401 — экран входа без текста ошибки; сеть/5xx — «хаб не отвечает»; прочее — текст ошибки + const onError = (e: unknown) => { + if (e instanceof AuthError) { dropTokenOn(e); if (alive()) put(id, IDLE); } + else fail(e instanceof HubDownError ? "хаб не отвечает" : errorText(e)); + }; // разбор состояния: ready — забрать файл, fetching — подождать и спросить снова, иначе — ошибка const handle = async (st: AttachmentState): Promise => { @@ -65,13 +70,13 @@ export function useAttachmentDownload(issueKey: string) { const blob = await api.blob(`/attachments/${encodeURIComponent(id)}/content`); if (!alive()) return; save(blob, a.filename); - put(id, IDLE); + put(id, { busy: false, error: null, ok: true }); void qc.invalidateQueries({ queryKey: qk.issue(issueKey) }); // отметка «у хаба» } else if (st.state === "fetching") { const t = setTimeout(() => { timers.current.delete(t); if (!alive()) return; - api.get(`/attachments/${encodeURIComponent(id)}`).then(handle).catch((e) => fail(errorText(e))); + api.get(`/attachments/${encodeURIComponent(id)}`).then(handle).catch(onError); }, POLL_MS); timers.current.add(t); } else { @@ -79,9 +84,9 @@ export function useAttachmentDownload(issueKey: string) { } }; - put(id, { busy: true, error: null }); + put(id, { busy: true, error: null, ok: false }); api.post(`/attachments/${encodeURIComponent(id)}/fetch`, { author: "web" }) - .then(handle).catch((e) => fail(errorText(e))); + .then(handle).catch(onError); }, [issueKey, put, qc]); const status = useCallback((id: string): DownloadStatus => map[id] ?? IDLE, [map]); diff --git a/web/src/modules/jira/issues/wiki/inline.test.ts b/web/src/modules/jira/issues/wiki/inline.test.ts index 618a930..e3080bd 100644 --- a/web/src/modules/jira/issues/wiki/inline.test.ts +++ b/web/src/modules/jira/issues/wiki/inline.test.ts @@ -89,13 +89,17 @@ describe("parseInline: ссылки на вложения", () => { expect(parseInline("см. !скрин 1.png|thumbnail!", A)).toEqual([t("см. "), att("31337", t("скрин 1.png"))]); expect(parseInline("!скрин 1.png!", A)).toEqual([att("31337", t("скрин 1.png"))]); }); + it("два вложения с одним именем — первое в порядке хаба, даже если created null", () => { + const o = { attachments: [{ id: "5", filename: "скрин.png", created: null }, { id: "9", filename: "скрин.png", created: "2026-10-20T10:00:00.000+0300" }] }; + expect(parseInline("!скрин.png!", o)).toEqual([att("5", t("скрин.png"))]); + }); it("[^имя] и [текст|^имя]", () => { expect(parseInline("[^a.pdf]", A)).toEqual([att("31338", t("a.pdf"))]); expect(parseInline("[отчёт|^a.pdf]", A)).toEqual([att("31338", t("отчёт"))]); }); - it("имя точно (с регистром); несколько с одним именем — самое новое", () => { + it("имя точно (с регистром); несколько с одним именем — первое в порядке хаба", () => { expect(parseInline("!Скрин 1.png!", A)).toEqual([t("!Скрин 1.png!")]); - expect(parseInline("!дубль.png!", A)).toEqual([att("20", t("дубль.png"))]); + expect(parseInline("!дубль.png!", A)).toEqual([att("10", t("дубль.png"))]); // первое в порядке хаба }); it("имени нет среди вложений — исходник как есть", () => { expect(parseInline("!нет.png!", A)).toEqual([t("!нет.png!")]); diff --git a/web/src/modules/jira/issues/wiki/inline.ts b/web/src/modules/jira/issues/wiki/inline.ts index b34ebb0..0ce6ab0 100644 --- a/web/src/modules/jira/issues/wiki/inline.ts +++ b/web/src/modules/jira/issues/wiki/inline.ts @@ -32,16 +32,9 @@ function safeHref(raw: string): string | null { return /^(https?:\/\/|mailto:)\S/i.test(href) ? href : null; } -// вложение по имени: несколько с одним именем — самое новое по created, затем по числовому id +// вложение по имени: несколько с одним именем — первое в порядке хаба function findAttachment(opts: WikiOptions, name: string): string | null { - const same = (opts.attachments ?? []).filter((a) => a.filename === name); - if (!same.length) return null; - const rank = (a: WikiAttachment): [number, number] => [a.created ? Date.parse(a.created) || 0 : 0, Number(a.id) || 0]; - return same.reduce((best, a) => { - const [bt, bi] = rank(best); - const [at, ai] = rank(a); - return at > bt || (at === bt && ai > bi) ? a : best; - }).id; + return (opts.attachments ?? []).find((a) => a.filename === name)?.id ?? null; } function keyPattern(projectKey: string | undefined): RegExp | null {