feat: 实现标签合并维护接口与n8n每日工作流
This commit is contained in:
@@ -7,11 +7,15 @@ export async function POST(request: NextRequest) {
|
||||
// 1. API Key 验证
|
||||
const apiKey = request.headers.get('X-API-Key');
|
||||
const expectedKey = process.env.WEBHOOK_API_KEY;
|
||||
const providedBuf = Buffer.from(apiKey || '');
|
||||
const expectedBuf = Buffer.from(expectedKey || '');
|
||||
|
||||
if (!apiKey || !expectedKey || !crypto.timingSafeEqual(
|
||||
Buffer.from(apiKey),
|
||||
Buffer.from(expectedKey)
|
||||
)) {
|
||||
if (
|
||||
!apiKey ||
|
||||
!expectedKey ||
|
||||
providedBuf.length !== expectedBuf.length ||
|
||||
!crypto.timingSafeEqual(providedBuf, expectedBuf)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{ error: 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
|
||||
@@ -1,148 +1,154 @@
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import { POST } from "./route";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "./route";
|
||||
|
||||
const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => {
|
||||
const tx = {
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
projectTag: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
transactionMock: vi.fn(async (callback: (tx: typeof tx) => unknown) => callback(tx)),
|
||||
revalidatePathMock: vi.fn(),
|
||||
txMock: tx,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
$transaction: transactionMock,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: revalidatePathMock,
|
||||
}));
|
||||
|
||||
function buildRequest(body: unknown): NextRequest {
|
||||
return new NextRequest("http://localhost:3000/api/tags/maintenance", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/tags/maintenance", () => {
|
||||
const validApiKey = "k".repeat(32);
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.WEBHOOK_API_KEY = "test-key-32-characters-long!!";
|
||||
process.env.WEBHOOK_API_KEY = validApiKey;
|
||||
transactionMock.mockClear();
|
||||
revalidatePathMock.mockClear();
|
||||
txMock.tag.findMany.mockReset();
|
||||
txMock.tag.findUnique.mockReset();
|
||||
txMock.tag.update.mockReset();
|
||||
txMock.tag.create.mockReset();
|
||||
txMock.tag.deleteMany.mockReset();
|
||||
txMock.projectTag.findMany.mockReset();
|
||||
txMock.projectTag.createMany.mockReset();
|
||||
});
|
||||
|
||||
it("should reject without API key", async () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/tags/maintenance", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ updates: [], merges: [] }),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Validation error");
|
||||
});
|
||||
|
||||
it("should reject with invalid API key format", async () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/tags/maintenance", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: "short",
|
||||
updates: [],
|
||||
merges: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Validation error");
|
||||
});
|
||||
|
||||
it("should reject with wrong API key", async () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/tags/maintenance", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
it("returns 401 for wrong API key", async () => {
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
apiKey: "a".repeat(32),
|
||||
updates: [],
|
||||
merges: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(401);
|
||||
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Unauthorized");
|
||||
});
|
||||
|
||||
it("should validate updates array structure", async () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/tags/maintenance", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: "test-key-32-characters-long!!",
|
||||
updates: [{ tagId: "" }],
|
||||
it("returns 400 for schema errors (missing nameEn)", async () => {
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
apiKey: validApiKey,
|
||||
updates: [{ tagId: "tag-1" }],
|
||||
merges: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Validation error");
|
||||
expect(transactionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should validate merges array structure", async () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/tags/maintenance", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: "test-key-32-characters-long!!",
|
||||
it("returns 400 for self merge payload", async () => {
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
apiKey: validApiKey,
|
||||
updates: [],
|
||||
merges: [{ target: {}, sourceTagIds: [] }],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(400);
|
||||
|
||||
merges: [
|
||||
{
|
||||
target: { id: "target-tag" },
|
||||
sourceTagIds: ["target-tag"],
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Validation error");
|
||||
expect(transactionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should accept valid updates with proper structure", async () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/tags/maintenance", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: "test-key-32-characters-long!!",
|
||||
updates: [{ tagId: "test-id", nameEn: "Test Tag English" }],
|
||||
it("returns 200 for authorized empty operations", async () => {
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
apiKey: validApiKey,
|
||||
updates: [],
|
||||
merges: [],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.result).toEqual({
|
||||
updatedCount: 0,
|
||||
mergedCount: 0,
|
||||
deletedTagCount: 0,
|
||||
});
|
||||
expect(revalidatePathMock).toHaveBeenCalledTimes(2);
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/zh/projects", "page");
|
||||
expect(revalidatePathMock).toHaveBeenCalledWith("/en/projects", "page");
|
||||
});
|
||||
|
||||
it("should accept valid merges with proper structure", async () => {
|
||||
const request = new NextRequest("http://localhost:3000/api/tags/maintenance", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
apiKey: "test-key-32-characters-long!!",
|
||||
updates: [],
|
||||
merges: [{ target: { name: "Target Tag" }, sourceTagIds: ["tag1", "tag2"] }],
|
||||
}),
|
||||
});
|
||||
|
||||
const response = await POST(request);
|
||||
expect(response.status).toBe(200);
|
||||
it("returns 400 for unknown tag IDs", async () => {
|
||||
txMock.tag.findMany.mockResolvedValue([]);
|
||||
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
apiKey: validApiKey,
|
||||
updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
|
||||
merges: [],
|
||||
})
|
||||
);
|
||||
const json = await response.json();
|
||||
expect(json.success).toBe(true);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Validation error");
|
||||
expect(json.details).toContain("Unknown tagId: missing-tag");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,7 +3,7 @@ import crypto from "crypto";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { TagMaintenanceRequestSchema } from "@/lib/validations";
|
||||
import { generateSlug } from "@/lib/slug";
|
||||
import { executeTagMaintenance, TagMaintenanceApiError } from "./service";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
@@ -45,104 +45,9 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// 4. Execute in transaction
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
let updatedCount = 0;
|
||||
let mergedCount = 0;
|
||||
let deletedTagCount = 0;
|
||||
|
||||
// 4a. Execute updates (nameEn补全)
|
||||
for (const update of updates) {
|
||||
await tx.tag.update({
|
||||
where: { id: update.tagId },
|
||||
data: { nameEn: update.nameEn },
|
||||
});
|
||||
updatedCount++;
|
||||
}
|
||||
|
||||
// 4b. Execute merges
|
||||
for (const merge of merges) {
|
||||
// Resolve or create target tag
|
||||
let targetTagId: string;
|
||||
|
||||
if ("id" in merge.target) {
|
||||
// Use existing tag
|
||||
targetTagId = merge.target.id;
|
||||
} else {
|
||||
// Create or find by name
|
||||
const slug = generateSlug(merge.target.name, merge.target.nameEn);
|
||||
const existing = await tx.tag.findFirst({
|
||||
where: {
|
||||
OR: [{ name: merge.target.name }, { slug }],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
if (existing) {
|
||||
// Update existing tag's nameEn
|
||||
await tx.tag.update({
|
||||
where: { id: existing.id },
|
||||
data: { nameEn: merge.target.nameEn },
|
||||
});
|
||||
targetTagId = existing.id;
|
||||
} else {
|
||||
// Create new tag
|
||||
const newTag = await tx.tag.create({
|
||||
data: {
|
||||
name: merge.target.name,
|
||||
nameEn: merge.target.nameEn,
|
||||
slug,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
targetTagId = newTag.id;
|
||||
}
|
||||
}
|
||||
|
||||
const sourceTagIds = merge.sourceTagIds.filter((id) => id !== targetTagId);
|
||||
if (sourceTagIds.length === 0) {
|
||||
mergedCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Get all projectIds from source tags
|
||||
const sourceProjectTags = await tx.projectTag.findMany({
|
||||
where: { tagId: { in: sourceTagIds } },
|
||||
select: { projectId: true },
|
||||
});
|
||||
|
||||
// Get existing projectIds for target tag (to avoid duplicates)
|
||||
const existingTargetProjectTags = await tx.projectTag.findMany({
|
||||
where: { tagId: targetTagId },
|
||||
select: { projectId: true },
|
||||
});
|
||||
const existingProjectIds = new Set(existingTargetProjectTags.map((pt) => pt.projectId));
|
||||
|
||||
// Filter to only new projectIds (deduplication)
|
||||
const newProjectIds = [...new Set(sourceProjectTags.map((pt) => pt.projectId))].filter(
|
||||
(pid) => !existingProjectIds.has(pid)
|
||||
);
|
||||
|
||||
// Create new ProjectTag entries for target
|
||||
if (newProjectIds.length > 0) {
|
||||
await tx.projectTag.createMany({
|
||||
data: newProjectIds.map((projectId) => ({
|
||||
projectId,
|
||||
tagId: targetTagId,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
// Delete source tags (cascade deletes their ProjectTags)
|
||||
const deleteResult = await tx.tag.deleteMany({
|
||||
where: { id: { in: sourceTagIds } },
|
||||
});
|
||||
|
||||
mergedCount++;
|
||||
deletedTagCount += deleteResult.count;
|
||||
}
|
||||
|
||||
return { updatedCount, mergedCount, deletedTagCount };
|
||||
});
|
||||
const result = await prisma.$transaction((tx) =>
|
||||
executeTagMaintenance(tx, { updates, merges })
|
||||
);
|
||||
|
||||
// 5. Revalidate ISR paths
|
||||
revalidatePath("/zh/projects", "page");
|
||||
@@ -153,6 +58,17 @@ export async function POST(request: NextRequest) {
|
||||
result,
|
||||
});
|
||||
} catch (error) {
|
||||
if (error instanceof TagMaintenanceApiError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: error.message,
|
||||
details: error.details,
|
||||
},
|
||||
{ status: error.status }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[POST /api/tags/maintenance] Error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { executeTagMaintenance, TagMaintenanceApiError } from "./service";
|
||||
|
||||
function createTxMock() {
|
||||
return {
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
update: vi.fn(),
|
||||
create: vi.fn(),
|
||||
deleteMany: vi.fn(),
|
||||
},
|
||||
projectTag: {
|
||||
findMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("executeTagMaintenance", () => {
|
||||
it("deduplicates projectTag migration and deletes source tags", async () => {
|
||||
const tx = createTxMock();
|
||||
tx.tag.findMany.mockResolvedValue([
|
||||
{ id: "target-tag" },
|
||||
{ id: "source-1" },
|
||||
{ id: "source-2" },
|
||||
]);
|
||||
tx.tag.update.mockResolvedValue({ id: "target-tag" });
|
||||
tx.projectTag.findMany.mockResolvedValue([
|
||||
{ projectId: "project-1" },
|
||||
{ projectId: "project-2" },
|
||||
{ projectId: "project-2" },
|
||||
]);
|
||||
tx.projectTag.createMany.mockResolvedValue({ count: 2 });
|
||||
tx.tag.deleteMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
const result = await executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], {
|
||||
updates: [],
|
||||
merges: [
|
||||
{
|
||||
target: { id: "target-tag", nameEn: "Machine Learning" },
|
||||
sourceTagIds: ["source-1", "source-2"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(tx.tag.update).toHaveBeenCalledWith({
|
||||
where: { id: "target-tag" },
|
||||
data: { nameEn: "Machine Learning" },
|
||||
});
|
||||
expect(tx.projectTag.createMany).toHaveBeenCalledWith({
|
||||
data: [
|
||||
{ projectId: "project-1", tagId: "target-tag" },
|
||||
{ projectId: "project-2", tagId: "target-tag" },
|
||||
],
|
||||
skipDuplicates: true,
|
||||
});
|
||||
expect(tx.tag.deleteMany).toHaveBeenCalledWith({
|
||||
where: { id: { in: ["source-1", "source-2"] } },
|
||||
});
|
||||
expect(result).toEqual({
|
||||
updatedCount: 0,
|
||||
mergedCount: 1,
|
||||
deletedTagCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws 400 for invalid tag IDs", async () => {
|
||||
const tx = createTxMock();
|
||||
tx.tag.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], {
|
||||
updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
|
||||
merges: [],
|
||||
})
|
||||
).rejects.toMatchObject<TagMaintenanceApiError>({
|
||||
status: 400,
|
||||
message: "Validation error",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,232 @@
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { generateSlug } from '@/lib/slug'
|
||||
import type { MergeTarget, TagMaintenanceRequest } from '@/lib/validations'
|
||||
|
||||
type MaintenancePayload = Omit<TagMaintenanceRequest, 'apiKey'>
|
||||
type TransactionClient = Prisma.TransactionClient
|
||||
|
||||
export type TagMaintenanceResult = {
|
||||
updatedCount: number
|
||||
mergedCount: number
|
||||
deletedTagCount: number
|
||||
}
|
||||
|
||||
export class TagMaintenanceApiError extends Error {
|
||||
status: number
|
||||
details: string[]
|
||||
|
||||
constructor(status: number, message: string, details: string[] = []) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.details = details
|
||||
this.name = 'TagMaintenanceApiError'
|
||||
}
|
||||
}
|
||||
|
||||
function isMergeTargetById(target: MergeTarget): target is Extract<MergeTarget, { id: string }> {
|
||||
return 'id' in target
|
||||
}
|
||||
|
||||
function isUniqueConstraintError(error: unknown): error is Prisma.PrismaClientKnownRequestError {
|
||||
return (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
)
|
||||
}
|
||||
|
||||
function collectReferencedTagIds(payload: MaintenancePayload): string[] {
|
||||
const ids = new Set<string>()
|
||||
|
||||
for (const update of payload.updates) {
|
||||
ids.add(update.tagId)
|
||||
}
|
||||
|
||||
for (const merge of payload.merges) {
|
||||
for (const sourceTagId of merge.sourceTagIds) {
|
||||
ids.add(sourceTagId)
|
||||
}
|
||||
if (isMergeTargetById(merge.target)) {
|
||||
ids.add(merge.target.id)
|
||||
}
|
||||
}
|
||||
|
||||
return [...ids]
|
||||
}
|
||||
|
||||
async function assertTagIdsExist(
|
||||
tx: TransactionClient,
|
||||
tagIds: string[]
|
||||
): Promise<void> {
|
||||
if (tagIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const existingTags = await tx.tag.findMany({
|
||||
where: { id: { in: tagIds } },
|
||||
select: { id: true },
|
||||
})
|
||||
const existingTagIds = new Set(existingTags.map((tag) => tag.id))
|
||||
|
||||
const missingTagIds = tagIds.filter((tagId) => !existingTagIds.has(tagId))
|
||||
if (missingTagIds.length > 0) {
|
||||
throw new TagMaintenanceApiError(
|
||||
400,
|
||||
'Validation error',
|
||||
missingTagIds.map((tagId) => `Unknown tagId: ${tagId}`)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveTargetTagId(
|
||||
tx: TransactionClient,
|
||||
target: MergeTarget
|
||||
): Promise<string> {
|
||||
if (isMergeTargetById(target)) {
|
||||
if (target.name !== undefined || target.nameEn !== undefined) {
|
||||
const data: { name?: string; nameEn?: string } = {}
|
||||
if (target.name !== undefined) {
|
||||
data.name = target.name
|
||||
}
|
||||
if (target.nameEn !== undefined) {
|
||||
data.nameEn = target.nameEn
|
||||
}
|
||||
|
||||
try {
|
||||
await tx.tag.update({
|
||||
where: { id: target.id },
|
||||
data,
|
||||
})
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new TagMaintenanceApiError(
|
||||
409,
|
||||
'Tag conflict',
|
||||
['Failed to update target tag due to name or slug conflict']
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
return target.id
|
||||
}
|
||||
|
||||
const slug = generateSlug(target.name, target.nameEn)
|
||||
const existingByName = await tx.tag.findUnique({
|
||||
where: { name: target.name },
|
||||
select: { id: true, nameEn: true },
|
||||
})
|
||||
|
||||
if (existingByName) {
|
||||
if (existingByName.nameEn !== target.nameEn) {
|
||||
await tx.tag.update({
|
||||
where: { id: existingByName.id },
|
||||
data: { nameEn: target.nameEn },
|
||||
})
|
||||
}
|
||||
return existingByName.id
|
||||
}
|
||||
|
||||
const existingBySlug = await tx.tag.findUnique({
|
||||
where: { slug },
|
||||
select: { name: true },
|
||||
})
|
||||
if (existingBySlug) {
|
||||
throw new TagMaintenanceApiError(
|
||||
409,
|
||||
'Tag conflict',
|
||||
[`Target slug "${slug}" already exists on tag "${existingBySlug.name}"`]
|
||||
)
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await tx.tag.create({
|
||||
data: {
|
||||
name: target.name,
|
||||
nameEn: target.nameEn,
|
||||
slug,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
return created.id
|
||||
} catch (error) {
|
||||
if (isUniqueConstraintError(error)) {
|
||||
throw new TagMaintenanceApiError(
|
||||
409,
|
||||
'Tag conflict',
|
||||
['Failed to create target tag due to unique constraint conflict']
|
||||
)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
async function migrateProjectTags(
|
||||
tx: TransactionClient,
|
||||
targetTagId: string,
|
||||
sourceTagIds: string[]
|
||||
): Promise<void> {
|
||||
const sourceProjectTags = await tx.projectTag.findMany({
|
||||
where: { tagId: { in: sourceTagIds } },
|
||||
select: { projectId: true },
|
||||
})
|
||||
const uniqueProjectIds = [...new Set(sourceProjectTags.map((tag) => tag.projectId))]
|
||||
|
||||
if (uniqueProjectIds.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
await tx.projectTag.createMany({
|
||||
data: uniqueProjectIds.map((projectId) => ({
|
||||
projectId,
|
||||
tagId: targetTagId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
}
|
||||
|
||||
export async function executeTagMaintenance(
|
||||
tx: TransactionClient,
|
||||
payload: MaintenancePayload
|
||||
): Promise<TagMaintenanceResult> {
|
||||
const referencedTagIds = collectReferencedTagIds(payload)
|
||||
await assertTagIdsExist(tx, referencedTagIds)
|
||||
|
||||
let updatedCount = 0
|
||||
let mergedCount = 0
|
||||
let deletedTagCount = 0
|
||||
|
||||
for (const update of payload.updates) {
|
||||
await tx.tag.update({
|
||||
where: { id: update.tagId },
|
||||
data: { nameEn: update.nameEn },
|
||||
})
|
||||
updatedCount++
|
||||
}
|
||||
|
||||
for (const merge of payload.merges) {
|
||||
const targetTagId = await resolveTargetTagId(tx, merge.target)
|
||||
const sourceTagIds = [...new Set(merge.sourceTagIds)].filter(
|
||||
(sourceTagId) => sourceTagId !== targetTagId
|
||||
)
|
||||
|
||||
if (sourceTagIds.length === 0) {
|
||||
mergedCount++
|
||||
continue
|
||||
}
|
||||
|
||||
await migrateProjectTags(tx, targetTagId, sourceTagIds)
|
||||
|
||||
const deleteResult = await tx.tag.deleteMany({
|
||||
where: { id: { in: sourceTagIds } },
|
||||
})
|
||||
deletedTagCount += deleteResult.count
|
||||
mergedCount++
|
||||
}
|
||||
|
||||
return {
|
||||
updatedCount,
|
||||
mergedCount,
|
||||
deletedTagCount,
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,57 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { GET } from "./route";
|
||||
|
||||
const { findManyMock } = vi.hoisted(() => ({
|
||||
findManyMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
tag: {
|
||||
findMany: findManyMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
describe("GET /api/tags", () => {
|
||||
it("should return success with tags array", async () => {
|
||||
const response = await GET();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
|
||||
const json = await response.json();
|
||||
expect(json.success).toBe(true);
|
||||
expect(json).toHaveProperty("tags");
|
||||
expect(Array.isArray(json.tags)).toBe(true);
|
||||
beforeEach(() => {
|
||||
findManyMock.mockReset();
|
||||
});
|
||||
|
||||
it("should include _count.projects in tags", async () => {
|
||||
it("returns tags list with project count", async () => {
|
||||
findManyMock.mockResolvedValue([
|
||||
{
|
||||
id: "tag-1",
|
||||
name: "机器学习",
|
||||
nameEn: "Machine Learning",
|
||||
slug: "machine-learning",
|
||||
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
||||
_count: { projects: 4 },
|
||||
},
|
||||
]);
|
||||
|
||||
const response = await GET();
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
expect(json.tags).toHaveLength(1);
|
||||
expect(json.tags[0]).toMatchObject({
|
||||
id: "tag-1",
|
||||
slug: "machine-learning",
|
||||
_count: { projects: 4 },
|
||||
});
|
||||
});
|
||||
|
||||
it("returns 500 when prisma query fails", async () => {
|
||||
findManyMock.mockRejectedValue(new Error("db unavailable"));
|
||||
|
||||
const response = await GET();
|
||||
const json = await response.json();
|
||||
if (json.tags.length > 0) {
|
||||
expect(json.tags[0]).toHaveProperty("_count");
|
||||
expect(json.tags[0]._count).toHaveProperty("projects");
|
||||
}
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(json.success).toBe(false);
|
||||
expect(json.error).toBe("Internal server error");
|
||||
expect(json.details).toContain("db unavailable");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TagMaintenanceRequestSchema } from "./validations";
|
||||
|
||||
describe("TagMaintenanceRequestSchema", () => {
|
||||
const apiKey = "k".repeat(32);
|
||||
|
||||
it("accepts empty updates and merges", () => {
|
||||
const result = TagMaintenanceRequestSchema.safeParse({
|
||||
apiKey,
|
||||
updates: [],
|
||||
merges: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects duplicate update tag IDs", () => {
|
||||
const result = TagMaintenanceRequestSchema.safeParse({
|
||||
apiKey,
|
||||
updates: [
|
||||
{ tagId: "tag-1", nameEn: "Tag 1" },
|
||||
{ tagId: "tag-1", nameEn: "Tag One" },
|
||||
],
|
||||
merges: [],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.errors[0]?.message).toContain("Duplicate update tag ID");
|
||||
});
|
||||
|
||||
it("rejects self merge", () => {
|
||||
const result = TagMaintenanceRequestSchema.safeParse({
|
||||
apiKey,
|
||||
updates: [],
|
||||
merges: [
|
||||
{
|
||||
target: { id: "tag-1" },
|
||||
sourceTagIds: ["tag-1", "tag-2"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.errors[0]?.message).toContain("Self merge is not allowed");
|
||||
});
|
||||
|
||||
it("rejects one source tag used in multiple merges", () => {
|
||||
const result = TagMaintenanceRequestSchema.safeParse({
|
||||
apiKey,
|
||||
updates: [],
|
||||
merges: [
|
||||
{
|
||||
target: { id: "target-1" },
|
||||
sourceTagIds: ["source-1"],
|
||||
},
|
||||
{
|
||||
target: { id: "target-2" },
|
||||
sourceTagIds: ["source-1"],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error?.errors[0]?.message).toContain("Source tag ID appears in multiple merges");
|
||||
});
|
||||
});
|
||||
+63
-7
@@ -269,23 +269,79 @@ export const TagUpdateSchema = z.object({
|
||||
nameEn: z.string().min(1, "English name is required").max(100),
|
||||
});
|
||||
|
||||
export const MergeTargetSchema = z.union([
|
||||
z.object({ id: z.string().min(1) }),
|
||||
z.object({
|
||||
name: z.string().min(1).max(100),
|
||||
nameEn: z.string().min(1).max(100),
|
||||
}),
|
||||
]);
|
||||
const ExistingMergeTargetSchema = z.object({
|
||||
id: z.string().min(1, "Target tag ID is required"),
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
nameEn: z.string().min(1).max(100).optional(),
|
||||
});
|
||||
|
||||
const NewMergeTargetSchema = z.object({
|
||||
name: z.string().min(1, "Target name is required").max(100),
|
||||
nameEn: z.string().min(1, "Target English name is required").max(100),
|
||||
});
|
||||
|
||||
export const MergeTargetSchema = z.union([ExistingMergeTargetSchema, NewMergeTargetSchema]);
|
||||
|
||||
export const TagMergeSchema = z.object({
|
||||
target: MergeTargetSchema,
|
||||
sourceTagIds: z.array(z.string().min(1)).min(1, "At least one source tag required"),
|
||||
}).superRefine((data, ctx) => {
|
||||
const seenSourceIds = new Set<string>();
|
||||
|
||||
data.sourceTagIds.forEach((sourceTagId, index) => {
|
||||
if (seenSourceIds.has(sourceTagId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sourceTagIds", index],
|
||||
message: `Duplicate source tag ID: ${sourceTagId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenSourceIds.add(sourceTagId);
|
||||
});
|
||||
|
||||
if ("id" in data.target && seenSourceIds.has(data.target.id)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["sourceTagIds"],
|
||||
message: "Self merge is not allowed: target tag cannot be in sourceTagIds",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const TagMaintenanceRequestSchema = z.object({
|
||||
apiKey: z.string().min(32, "Invalid API key format"),
|
||||
updates: z.array(TagUpdateSchema).default([]),
|
||||
merges: z.array(TagMergeSchema).default([]),
|
||||
}).superRefine((data, ctx) => {
|
||||
const seenUpdateTagIds = new Set<string>();
|
||||
const seenMergeSourceTagIds = new Set<string>();
|
||||
|
||||
data.updates.forEach((update, index) => {
|
||||
if (seenUpdateTagIds.has(update.tagId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["updates", index, "tagId"],
|
||||
message: `Duplicate update tag ID: ${update.tagId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenUpdateTagIds.add(update.tagId);
|
||||
});
|
||||
|
||||
data.merges.forEach((merge, mergeIndex) => {
|
||||
merge.sourceTagIds.forEach((sourceTagId, sourceIndex) => {
|
||||
if (seenMergeSourceTagIds.has(sourceTagId)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["merges", mergeIndex, "sourceTagIds", sourceIndex],
|
||||
message: `Source tag ID appears in multiple merges: ${sourceTagId}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
seenMergeSourceTagIds.add(sourceTagId);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ================================
|
||||
|
||||
Reference in New Issue
Block a user