feat(feedback): add problem reporting flow

This commit is contained in:
2026-07-16 18:30:34 +08:00
parent 4981de9b18
commit 37f45f0736
34 changed files with 1866 additions and 4 deletions
@@ -0,0 +1,53 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const httpClientMock = vi.hoisted(() => vi.fn());
vi.mock("../http_client", () => ({
httpClient: httpClientMock,
}));
import { FeedbackApi } from "../feedback_api";
describe("FeedbackApi", () => {
beforeEach(() => {
httpClientMock.mockReset();
});
it("submits the supported fields as multipart data", async () => {
httpClientMock.mockResolvedValue({
success: true,
data: { feedbackId: "feedback-123", ignored: "server-only" },
});
const image = new File(["image"], "screen.jpg", {
type: "image/jpeg",
});
const response = await new FeedbackApi().submit({
category: "problem",
content: "The send button did not respond.",
context: {
appVersion: "0.1.0",
platform: "android Android 15",
browser: "facebook IAB / Chrome 135",
viewport: "392x760@2.75",
},
images: [image],
});
expect(response.toJson()).toEqual({ feedbackId: "feedback-123" });
expect(httpClientMock).toHaveBeenCalledWith(
"/api/feedback",
expect.objectContaining({ method: "POST", body: expect.any(FormData) }),
);
const body = httpClientMock.mock.calls[0][1].body as FormData;
expect(body.get("category")).toBe("problem");
expect(body.get("content")).toBe("The send button did not respond.");
expect(JSON.parse(String(body.get("context")))).toEqual({
appVersion: "0.1.0",
platform: "android Android 15",
browser: "facebook IAB / Chrome 135",
viewport: "392x760@2.75",
});
expect(body.getAll("images")).toEqual([image]);
});
});
@@ -22,6 +22,13 @@ function callUrl(call: Parameters<typeof fetch>): URL {
return new URL(request instanceof Request ? request.url : String(request));
}
function callHeaders(call: Parameters<typeof fetch>): Headers {
const [request, init] = call;
return request instanceof Request
? request.headers
: new Headers(init?.headers);
}
describe("createHttpClient", () => {
beforeEach(() => {
process.env.NEXT_PUBLIC_API_BASE_URL = "https://api.example.test";
@@ -264,4 +271,35 @@ describe("createHttpClient", () => {
]);
await expect(authStorage.getGuestToken()).resolves.toEqual(Result.ok(null));
});
it("lets ofetch set JSON content headers for object bodies", async () => {
const fetchMock = vi.fn<typeof fetch>(async () =>
jsonResponse({ success: true }),
);
vi.stubGlobal("fetch", fetchMock);
const http = createHttpClient();
await http("/api/example", {
method: "POST",
body: { message: "hello" },
});
expect(callHeaders(fetchMock.mock.calls[0]).get("Content-Type")).toContain(
"application/json",
);
});
it("does not override multipart boundaries for FormData bodies", async () => {
const fetchMock = vi.fn<typeof fetch>(async () =>
jsonResponse({ success: true }),
);
vi.stubGlobal("fetch", fetchMock);
const body = new FormData();
body.append("content", "feedback");
const http = createHttpClient();
await http("/api/feedback", { method: "POST", body });
expect(callHeaders(fetchMock.mock.calls[0]).get("Content-Type")).toBeNull();
});
});
+3
View File
@@ -103,4 +103,7 @@ export class ApiPath {
/** 上报用户信息 */
static readonly reportUserInfo = `${ApiPath._data}/report-user-info`;
// ============ 用户反馈相关 ============
static readonly feedback = `${ApiPath._baseUrl}/feedback`;
}
+26
View File
@@ -0,0 +1,26 @@
import {
FeedbackSubmitResponse,
type SubmitFeedbackInput,
} from "@/data/dto/feedback";
import { ApiPath } from "./api_path";
import { httpClient } from "./http_client";
import { type ApiEnvelope, unwrap } from "./response_helper";
export class FeedbackApi {
async submit(input: SubmitFeedbackInput): Promise<FeedbackSubmitResponse> {
const body = new FormData();
body.append("category", input.category);
body.append("content", input.content);
body.append("context", JSON.stringify(input.context));
input.images.forEach((image) => body.append("images", image));
const envelope = await httpClient<ApiEnvelope<unknown>>(ApiPath.feedback, {
method: "POST",
body,
});
return FeedbackSubmitResponse.fromJson(unwrap(envelope));
}
}
export const feedbackApi = new FeedbackApi();
-3
View File
@@ -44,9 +44,6 @@ export function createHttpClient(): $Fetch {
timeout: config.receiveTimeout,
retry: 1, // 401 刷新后由 ofetch 重放一次原请求
retryStatusCodes: [ErrorCode.httpUnauthorized],
headers: {
"Content-Type": "application/json",
},
onRequest: chain(
loggingInterceptor.onRequest,
tokenInterceptor.onRequest
+1
View File
@@ -6,6 +6,7 @@ export * from "./api_path";
export * from "./api_result";
export * from "./auth_api";
export * from "./chat_api";
export * from "./feedback_api";
export * from "./http_client";
export * from "./metrics_api";
export * from "./payment_api";