fix: support safe project deletion
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
# Task: Diagnose and fix project deletion
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260813-project-delete-fix-d3633d68
|
||||
- Mode: Feature
|
||||
- Branch: main
|
||||
- Worktree: D:\Datas\PythonProjects\XQKqueue
|
||||
- Base commit: 1eb36645e41dfd479799470df65d5cb8a8ac077e
|
||||
- Owner: codex
|
||||
- Status: Complete; ready for integration
|
||||
|
||||
## Scope
|
||||
|
||||
- Diagnose the missing administrator project-deletion workflow across the React admin client and Go HTTP service.
|
||||
- Add a safe deletion contract for projects that have no retained operational history.
|
||||
- Preserve queue sessions and their ticket, call-batch, and simulation history by rejecting deletion when retained operational data exists; preserve audit rows by detaching the project foreign key.
|
||||
- Cover the administrator UI/API request and the server deletion boundary with regression tests.
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- The user's confirmed symptom is that a project cannot be deleted; the repository currently exposes no project-delete UI action, web API method, HTTP route, or handler.
|
||||
- PostgreSQL remains the sole write authority. The client must not infer deletion eligibility.
|
||||
- Existing `ON DELETE RESTRICT` history relationships must not be replaced with broad cascading deletion.
|
||||
- The working product assumption is that deletion means removing an unused/accidentally-created project; projects with retained operational history must be rejected with an explicit conflict response, while audit history must remain available without retaining a project foreign key.
|
||||
- Keep the change surgical and reuse the existing admin authentication, transaction, API-error, polling-refresh, and feedback patterns.
|
||||
|
||||
## Outcome
|
||||
|
||||
- Confirmed the reported behavior was a missing capability rather than a failing existing request: the admin UI had no delete action, the web API client had no delete method, and the Go server had no DELETE route or handler.
|
||||
- Added an administrator-only `DELETE /api/admin/projects/{id}` endpoint and registered it as an important structured-log route.
|
||||
- Implemented guarded deletion in one transaction: lock the project row, reject projects with any queue session using `409 PROJECT_HAS_HISTORY`, detach retained project-scoped audits, write an unscoped `PROJECT_DELETED` snapshot audit, and delete the otherwise-unused project.
|
||||
- Preserved the existing database ownership of ephemeral cascades (`user_projects` and `idempotency_keys`) and did not add broad cascades or delete queue, ticket, call-batch, device, or audit history.
|
||||
- Added a two-step destructive action to the project maintenance page, including in-flight duplicate prevention, server error feedback, refresh, and replace-navigation to the project list after success.
|
||||
- Added a danger-color button variant so deletion is visually distinct from ordinary project maintenance.
|
||||
- Added web API/component regression coverage and full-handler PostgreSQL integration coverage for authentication, successful unused-project deletion, retained audit history, and atomic rejection of a project with queue history.
|
||||
|
||||
## Verification
|
||||
|
||||
- Red signal: before implementation, the new web deletion tests failed because `api.deleteProject` and the delete controls did not exist; the new Go DELETE route test returned 405 instead of reaching admin authentication.
|
||||
- Focused web: `pnpm exec vitest run src/api.test.ts src/pages/AdminPage.test.tsx` passed, 2 files / 22 tests.
|
||||
- The web API regression uses the server's actual empty `204 No Content` response contract rather than a JSON success fixture.
|
||||
- Full web: `pnpm test:run` passed, 18 files / 69 tests.
|
||||
- Web type/build: `pnpm typecheck` and `pnpm build` passed.
|
||||
- Focused real PostgreSQL: `go test ./internal/httpapi -run '^TestDeleteProject(PostgresIntegration|RouteRequiresAdminAuthentication)$' -count=1 -v` passed against a fresh isolated PostgreSQL 17 database.
|
||||
- Full server: `go test ./... -count=1` passed against a second fresh isolated PostgreSQL 17 database.
|
||||
- The integration test exercises the complete admin login-cookie and routed-handler chain. It verifies 204 with an empty body, project/grant removal, retained old audit rows with a null project foreign key, a deletion snapshot audit, and a 409 path with no partial mutation.
|
||||
- `git diff --check` passed with only existing Windows line-ending notices.
|
||||
- Server static checks: `go vet ./...` and `go build ./...` passed.
|
||||
- Independent read-only Sol review: PASS; no P0/P1 or unresolved P2/P3 findings. It verified PostgreSQL parent/FK lock compatibility, transactional rollback, 204 handling, audit detachment/snapshot semantics, schema-owned cascades, UI confirmation/in-flight/error/navigation behavior, logging, and task-document ownership.
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- Promote the guarded deletion rule and admin DELETE interface into canonical business-rule/data-flow documentation through a later Integration Gate.
|
||||
- If product policy later requires archived projects, deleted-code reservation, or deleted-project filtering in audit/history screens, treat that as a separate schema and cross-query design task rather than broadening this hard-delete endpoint.
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: `.project-docs/40-domain/business-rules.md`.
|
||||
Proposal: an administrator may permanently delete only a project with no queue session; any retained queue history blocks deletion with `PROJECT_HAS_HISTORY`. Deletion of an eligible unused project preserves audit entries and records a `PROJECT_DELETED` snapshot while removing the project and ephemeral grants/idempotency state.
|
||||
Evidence: `server/internal/httpapi/admin.go`, `server/internal/httpapi/project_delete_integration_test.go`, and the PostgreSQL verification recorded above.
|
||||
Future impact: future project relationships must either be classified as retained deletion blockers or explicitly ephemeral; clients must not decide eligibility locally or introduce broad history cascades.
|
||||
Semantic conflicts: none with current retention rules; the prior documents did not define project deletion.
|
||||
Human confirmation: the user requested that projects become deletable; the guarded empty-only interpretation was selected to preserve existing history and audit contracts.
|
||||
- Target: `.project-docs/20-architecture/data-flow.md`.
|
||||
Proposal: add `DELETE /api/admin/projects/{id}` to the admin interface list and record that the service transaction locks the project, enforces the queue-history guard, preserves detached audits, and owns deletion.
|
||||
Evidence: the registered server route, transaction handler, web client call, and full-handler integration test.
|
||||
Future impact: administrator clients should surface the service's 409 reason and must not emulate or bypass deletion rules.
|
||||
Semantic conflicts: none known.
|
||||
Human confirmation: no additional confirmation required for documenting the implemented interface.
|
||||
@@ -132,6 +132,50 @@ func (s *Server) updateProject(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, http.StatusOK, map[string]any{"project": projectView(project)})
|
||||
}
|
||||
|
||||
func (s *Server) deleteProject(w http.ResponseWriter, r *http.Request) {
|
||||
projectID := r.PathValue("id")
|
||||
if err := validateUUID(projectID); err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
err := s.db.WithContext(r.Context()).Transaction(func(tx *gorm.DB) error {
|
||||
var project model.Project
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&project, "id = ?", projectID).Error; err != nil {
|
||||
return mapNotFound(err, "PROJECT_NOT_FOUND", "项目不存在")
|
||||
}
|
||||
|
||||
var historyCount int64
|
||||
if err := tx.Model(&model.QueueSession{}).Where("project_id = ?", projectID).Count(&historyCount).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if historyCount > 0 {
|
||||
return &apiError{
|
||||
Status: http.StatusConflict,
|
||||
Code: "PROJECT_HAS_HISTORY",
|
||||
Message: "该项目已有排队运营历史,不能删除;如需停止使用,请结束该项目。",
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Model(&model.AuditEntry{}).
|
||||
Where("project_id = ?", projectID).
|
||||
Update("project_id", nil).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
actor := currentPrincipal(r.Context()).User
|
||||
if err := s.addAudit(tx, r, nil, &actor.ID, "PROJECT_DELETED", "PROJECT", &projectID,
|
||||
map[string]any{"project": projectView(project)}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Delete(&project).Error
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, err)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
type adminUserRequest struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
|
||||
@@ -45,6 +45,7 @@ var (
|
||||
"PUT /api/admin/users/{id}": {},
|
||||
"POST /api/admin/projects": {},
|
||||
"PUT /api/admin/projects/{id}": {},
|
||||
"DELETE /api/admin/projects/{id}": {},
|
||||
"PUT /api/admin/projects/{id}/settings": {},
|
||||
"GET /api/admin/history/tickets": {},
|
||||
"GET /api/admin/history/tickets/{id}": {},
|
||||
|
||||
238
server/internal/httpapi/project_delete_integration_test.go
Normal file
238
server/internal/httpapi/project_delete_integration_test.go
Normal file
@@ -0,0 +1,238 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"calllinesystem/server/internal/config"
|
||||
"calllinesystem/server/internal/database"
|
||||
"calllinesystem/server/internal/model"
|
||||
"calllinesystem/server/internal/security"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestDeleteProjectRouteRequiresAdminAuthentication(t *testing.T) {
|
||||
server := &Server{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
now: func() time.Time { return time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC) },
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodDelete, "/api/admin/projects/11111111-1111-4111-8111-111111111111", nil)
|
||||
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
|
||||
if recorder.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("delete project without admin session status = %d, want 401; body = %s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProjectPostgresIntegration(t *testing.T) {
|
||||
dsn := strings.TrimSpace(os.Getenv("TEST_DATABASE_URL"))
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL is not set")
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
ctx := t.Context()
|
||||
db, err := database.Open(ctx, dsn, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer database.Close(db)
|
||||
sqlDB, err := database.SQLDB(db)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := database.Migrate(ctx, sqlDB, logger); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server, err := New(db, config.Config{
|
||||
EncryptionKey: bytes.Repeat([]byte{0x61}, 32),
|
||||
PhoneHMACKey: bytes.Repeat([]byte{0x62}, 32),
|
||||
SessionCookieName: "queue_session",
|
||||
SessionTTL: time.Hour,
|
||||
}, logger)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 8, 13, 10, 0, 0, 0, time.UTC)
|
||||
server.now = func() time.Time { return now }
|
||||
|
||||
password := "AdminPassword123!"
|
||||
passwordHash, err := security.HashPassword(password)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:10]
|
||||
admin := model.User{
|
||||
ID: uuid.NewString(), Username: "delete_admin_" + suffix, PasswordHash: passwordHash,
|
||||
Role: model.RoleAdmin, Active: true, CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(&admin).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
newProject := func(code, name string) model.Project {
|
||||
return model.Project{
|
||||
ID: uuid.NewString(), Code: code, Name: name, Status: model.ProjectNotOpen,
|
||||
Timezone: "Asia/Shanghai", TicketPrefix: "A", CallBatchSize: 1,
|
||||
CallMode: model.CallModeBoth, MaxCallTicketCount: 100,
|
||||
DefaultCallPeopleCount: 1, MaxCallPeopleCount: 100, MinPartySize: 1, MaxPartySize: 10,
|
||||
GracePeriodMinutes: 5, ETAMode: model.ETAFixedBatch, AverageBatchIntervalSeconds: 300,
|
||||
ContinuousRatePerMinute: 1, ETAIntervalSeconds: 60, VisitorNotice: model.DefaultVisitorNotice,
|
||||
DeviceSimulationMode: "DISABLED", CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
unused := newProject("DEL"+strings.ToUpper(suffix), "unused deletion project")
|
||||
history := newProject("HIS"+strings.ToUpper(suffix), "history deletion project")
|
||||
if err := db.Create(&unused).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&history).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
grant := model.UserProject{UserID: admin.ID, ProjectID: unused.ID, CreatedAt: now}
|
||||
if err := db.Create(&grant).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
createdAudit := func(projectID string) model.AuditEntry {
|
||||
return model.AuditEntry{
|
||||
ID: uuid.NewString(), ProjectID: &projectID, ActorUserID: &admin.ID,
|
||||
Action: "PROJECT_CREATED", EntityType: "PROJECT", EntityID: &projectID,
|
||||
Details: []byte(`{"source":"integration-test"}`), RequestID: "integration-create",
|
||||
UserAgent: "integration-test", RetainUntil: now.AddDate(1, 0, 0), CreatedAt: now,
|
||||
}
|
||||
}
|
||||
unusedCreatedAudit := createdAudit(unused.ID)
|
||||
historyCreatedAudit := createdAudit(history.ID)
|
||||
if err := db.Create(&unusedCreatedAudit).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&historyCreatedAudit).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
queueSession := model.QueueSession{
|
||||
ID: uuid.NewString(), ProjectID: history.ID, BusinessDate: now,
|
||||
Status: "ENDED", NextTicketNumber: 1, Revision: 0, OpenedAt: now,
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
if err := db.Create(&queueSession).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
loginRequest := httptest.NewRequest(http.MethodPost, "/api/admin/auth/login",
|
||||
strings.NewReader(`{"username":"`+admin.Username+`","password":"`+password+`"}`))
|
||||
server.Handler().ServeHTTP(loginRecorder, loginRequest)
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("admin login status = %d, want 200; body = %s", loginRecorder.Code, loginRecorder.Body.String())
|
||||
}
|
||||
var adminCookie *http.Cookie
|
||||
for _, cookie := range loginRecorder.Result().Cookies() {
|
||||
if cookie.Name == server.authCookieName(model.RoleAdmin) {
|
||||
adminCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
if adminCookie == nil {
|
||||
t.Fatal("admin login did not set the admin session cookie")
|
||||
}
|
||||
|
||||
deleteRequest := func(projectID string) *httptest.ResponseRecorder {
|
||||
recorder := httptest.NewRecorder()
|
||||
request := httptest.NewRequest(http.MethodDelete, "/api/admin/projects/"+projectID, nil)
|
||||
request.AddCookie(adminCookie)
|
||||
server.Handler().ServeHTTP(recorder, request)
|
||||
return recorder
|
||||
}
|
||||
|
||||
t.Run("unused project is deleted while audit history is retained", func(t *testing.T) {
|
||||
response := deleteRequest(unused.ID)
|
||||
if response.Code != http.StatusNoContent {
|
||||
t.Fatalf("delete unused project status = %d, want 204; body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if response.Body.Len() != 0 {
|
||||
t.Fatalf("delete unused project body = %q, want empty", response.Body.String())
|
||||
}
|
||||
|
||||
if err := db.First(&model.Project{}, "id = ?", unused.ID).Error; !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
t.Fatalf("deleted project lookup error = %v, want record not found", err)
|
||||
}
|
||||
var grantCount int64
|
||||
if err := db.Model(&model.UserProject{}).Where("project_id = ?", unused.ID).Count(&grantCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if grantCount != 0 {
|
||||
t.Fatalf("project grants after deletion = %d, want 0", grantCount)
|
||||
}
|
||||
|
||||
var retained model.AuditEntry
|
||||
if err := db.First(&retained, "id = ?", unusedCreatedAudit.ID).Error; err != nil {
|
||||
t.Fatalf("load retained PROJECT_CREATED audit: %v", err)
|
||||
}
|
||||
if retained.ProjectID != nil {
|
||||
t.Fatalf("retained PROJECT_CREATED project_id = %v, want nil", *retained.ProjectID)
|
||||
}
|
||||
|
||||
var deletedAudit model.AuditEntry
|
||||
if err := db.Where("action = ? AND entity_type = ? AND entity_id = ?", "PROJECT_DELETED", "PROJECT", unused.ID).
|
||||
First(&deletedAudit).Error; err != nil {
|
||||
t.Fatalf("load PROJECT_DELETED audit: %v", err)
|
||||
}
|
||||
if deletedAudit.ProjectID != nil {
|
||||
t.Fatalf("PROJECT_DELETED project_id = %v, want nil", *deletedAudit.ProjectID)
|
||||
}
|
||||
if deletedAudit.EntityID == nil || *deletedAudit.EntityID != unused.ID {
|
||||
t.Fatalf("PROJECT_DELETED entity_id = %v, want %s", deletedAudit.EntityID, unused.ID)
|
||||
}
|
||||
if !bytes.Contains(deletedAudit.Details, []byte(unused.Code)) {
|
||||
t.Fatalf("PROJECT_DELETED details = %s, want deleted project snapshot", deletedAudit.Details)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("project with queue history is rejected intact", func(t *testing.T) {
|
||||
response := deleteRequest(history.ID)
|
||||
if response.Code != http.StatusConflict {
|
||||
t.Fatalf("delete history project status = %d, want 409; body = %s", response.Code, response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), `"code":"PROJECT_HAS_HISTORY"`) {
|
||||
t.Fatalf("delete history project body = %s, want PROJECT_HAS_HISTORY", response.Body.String())
|
||||
}
|
||||
if !strings.Contains(response.Body.String(), "不能删除") || !strings.Contains(response.Body.String(), "结束") {
|
||||
t.Fatalf("delete history project message is not actionable Chinese: %s", response.Body.String())
|
||||
}
|
||||
if err := db.First(&model.Project{}, "id = ?", history.ID).Error; err != nil {
|
||||
t.Fatalf("history project was not retained: %v", err)
|
||||
}
|
||||
if err := db.First(&model.QueueSession{}, "id = ?", queueSession.ID).Error; err != nil {
|
||||
t.Fatalf("queue session was not retained: %v", err)
|
||||
}
|
||||
var retained model.AuditEntry
|
||||
if err := db.First(&retained, "id = ?", historyCreatedAudit.ID).Error; err != nil {
|
||||
t.Fatalf("history audit was not retained: %v", err)
|
||||
}
|
||||
if retained.ProjectID == nil || *retained.ProjectID != history.ID {
|
||||
t.Fatalf("history audit project_id = %v, want %s", retained.ProjectID, history.ID)
|
||||
}
|
||||
var deletedAuditCount int64
|
||||
if err := db.Model(&model.AuditEntry{}).
|
||||
Where("action = ? AND entity_id = ?", "PROJECT_DELETED", history.ID).
|
||||
Count(&deletedAuditCount).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deletedAuditCount != 0 {
|
||||
t.Fatalf("PROJECT_DELETED audit count for retained project = %d, want 0", deletedAuditCount)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -100,6 +100,7 @@ func (s *Server) Handler() http.Handler {
|
||||
mux.Handle("PUT /api/admin/users/{id}", s.requireAdmin(http.HandlerFunc(s.updateAdminUser)))
|
||||
mux.Handle("POST /api/admin/projects", s.requireAdmin(http.HandlerFunc(s.createProject)))
|
||||
mux.Handle("PUT /api/admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.updateProject)))
|
||||
mux.Handle("DELETE /api/admin/projects/{id}", s.requireAdmin(http.HandlerFunc(s.deleteProject)))
|
||||
mux.Handle("PUT /api/admin/projects/{id}/settings", s.requireAdmin(http.HandlerFunc(s.updateProjectSettings)))
|
||||
mux.Handle("GET /api/admin/history/tickets", s.requireAdmin(http.HandlerFunc(s.adminHistoryTickets)))
|
||||
mux.Handle("GET /api/admin/history/tickets/{id}", s.requireAdmin(http.HandlerFunc(s.adminHistoryTicket)))
|
||||
|
||||
@@ -13,6 +13,16 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("mutating queue requests", () => {
|
||||
it("deletes an admin project using its encoded id", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(null, { status: 204 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await api.deleteProject("project/one");
|
||||
|
||||
expect(fetchMock.mock.calls[0][0]).toBe("/api/admin/projects/project%2Fone");
|
||||
expect(fetchMock.mock.calls[0][1]).toMatchObject({ method: "DELETE" });
|
||||
});
|
||||
|
||||
it("sends an idempotency key when creating a ticket", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(jsonResponse({
|
||||
ticket: { id: "ticket-1", display_number: "00001", status: "WAITING", party_size: 3 },
|
||||
|
||||
@@ -245,6 +245,9 @@ export const api = {
|
||||
updateProject(projectId: string, payload: import("./types").ProjectProfilePayload) {
|
||||
return request<{ project: ProjectDto }>(`/api/admin/projects/${encodeURIComponent(projectId)}`, { method: "PUT", body: JSON.stringify(payload) });
|
||||
},
|
||||
deleteProject(projectId: string) {
|
||||
return request<void>(`/api/admin/projects/${encodeURIComponent(projectId)}`, { method: "DELETE" });
|
||||
},
|
||||
updateProjectSettings(projectId: string, payload: import("./types").UpdateProjectSettingsPayload) {
|
||||
return request<{ project: ProjectDto }>(`/api/admin/projects/${encodeURIComponent(projectId)}/settings`, {
|
||||
method: "PUT",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { MemoryRouter } from "react-router-dom";
|
||||
import { api } from "../api";
|
||||
import { ApiError, api } from "../api";
|
||||
|
||||
const { refreshMock } = vi.hoisted(() => ({ refreshMock: vi.fn() }));
|
||||
|
||||
vi.mock("../components/AppShell", () => ({ AppShell: ({ children }: { children: React.ReactNode }) => <div>{children}</div> }));
|
||||
vi.mock("../hooks/usePollingResource", () => ({
|
||||
@@ -71,12 +73,16 @@ vi.mock("../hooks/usePollingResource", () => ({
|
||||
error: null,
|
||||
offline: false,
|
||||
lastClientSuccessAt: new Date().toISOString(),
|
||||
refresh: vi.fn(),
|
||||
refresh: refreshMock,
|
||||
}),
|
||||
}));
|
||||
|
||||
import { AdminPage } from "./AdminPage";
|
||||
|
||||
beforeEach(() => {
|
||||
refreshMock.mockReset();
|
||||
});
|
||||
|
||||
describe("AdminPage active tickets", () => {
|
||||
it("保留核心排队数据并删除辅助概览模块", () => {
|
||||
render(<MemoryRouter initialEntries={["/admin"]}><AdminPage /></MemoryRouter>);
|
||||
@@ -145,6 +151,46 @@ describe("AdminPage active tickets", () => {
|
||||
expect(screen.queryByRole("combobox", { name: "预计等待时间方式" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("textbox", { name: "游客官方提示" })).toHaveValue("请在入口附近等候。");
|
||||
expect(screen.getByRole("button", { name: "保存项目" })).toBeVisible();
|
||||
expect(screen.getByRole("button", { name: "删除项目" })).toBeVisible();
|
||||
});
|
||||
|
||||
it("确认后删除项目,刷新数据并返回列表", async () => {
|
||||
let resolveDelete!: () => void;
|
||||
const deleteProject = vi.spyOn(api, "deleteProject").mockImplementation(() => new Promise<void>((resolve) => { resolveDelete = resolve; }));
|
||||
refreshMock.mockResolvedValueOnce(undefined);
|
||||
try {
|
||||
render(<MemoryRouter initialEntries={["/admin/projects/project-1"]}><AdminPage /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除项目" }));
|
||||
expect(deleteProject).not.toHaveBeenCalled();
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认删除" }));
|
||||
|
||||
await waitFor(() => expect(deleteProject).toHaveBeenCalledWith("project-1"));
|
||||
const deletingButton = screen.getByRole("button", { name: "正在删除" });
|
||||
expect(deletingButton).toBeDisabled();
|
||||
fireEvent.click(deletingButton);
|
||||
expect(deleteProject).toHaveBeenCalledTimes(1);
|
||||
resolveDelete();
|
||||
await waitFor(() => expect(refreshMock).toHaveBeenCalled());
|
||||
await waitFor(() => expect(screen.getByRole("heading", { name: "项目管理" })).toBeVisible());
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("删除项目失败时留在维护页并显示服务端消息", async () => {
|
||||
const deleteProject = vi.spyOn(api, "deleteProject").mockRejectedValue(new ApiError("项目存在活动排队,无法删除", 409));
|
||||
try {
|
||||
render(<MemoryRouter initialEntries={["/admin/projects/project-1"]}><AdminPage /></MemoryRouter>);
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "删除项目" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "确认删除" }));
|
||||
|
||||
expect(await screen.findByText("项目存在活动排队,无法删除")).toBeVisible();
|
||||
expect(screen.getByRole("heading", { name: "东门观光车" })).toBeVisible();
|
||||
} finally {
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
|
||||
it("创建项目与维护项目使用相同字段", () => {
|
||||
|
||||
@@ -337,10 +337,42 @@ function ProjectManagement({ projects }: { projects: AdminProjectDto[] }) {
|
||||
}
|
||||
|
||||
function ProjectMaintenance({ project, onRefresh }: { project?: AdminProjectDto; onRefresh: () => void | Promise<void> }) {
|
||||
const navigate = useNavigate();
|
||||
const [confirmingDelete, setConfirmingDelete] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
if (!project) return <section className="panel"><EmptyState title="未找到该项目" /><div className="account-maintenance__back"><Link className="button button--secondary" to="/admin/projects">返回项目列表</Link></div></section>;
|
||||
|
||||
const deleteProject = async () => {
|
||||
if (deleting) return;
|
||||
setDeleting(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.deleteProject(project.id);
|
||||
await onRefresh();
|
||||
navigate("/admin/projects", { replace: true });
|
||||
} catch (caught) {
|
||||
setDeleteError(caught instanceof ApiError ? caught.message : "项目删除失败,请稍后重试。");
|
||||
setDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return <div className="project-maintenance">
|
||||
<div className="panel__header project-maintenance__header"><h2>{project.name}</h2><Link className="button button--secondary" to="/admin/projects">返回列表</Link></div>
|
||||
<ProjectForm project={project} onSaved={onRefresh} />
|
||||
<section className="panel" aria-label="危险操作">
|
||||
<div className="panel__header">
|
||||
<div><h3>危险操作</h3><p>删除后无法撤销,请确认不再需要该项目。</p></div>
|
||||
{!confirmingDelete
|
||||
? <button className="button button--danger" type="button" onClick={() => { setConfirmingDelete(true); setDeleteError(null); }}>删除项目</button>
|
||||
: <div>
|
||||
<button className="button button--secondary" type="button" onClick={() => setConfirmingDelete(false)} disabled={deleting}>取消</button>{" "}
|
||||
<button className="button button--danger" type="button" onClick={deleteProject} disabled={deleting}>{deleting ? "正在删除" : "确认删除"}</button>
|
||||
</div>}
|
||||
</div>
|
||||
{deleteError ? <FeedbackBanner tone="danger" title="删除失败">{deleteError}</FeedbackBanner> : null}
|
||||
</section>
|
||||
</div>;
|
||||
}
|
||||
|
||||
|
||||
@@ -1311,6 +1311,16 @@ h3 {
|
||||
background: var(--color-primary-soft);
|
||||
}
|
||||
|
||||
.button--danger {
|
||||
border-color: var(--color-danger);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.button--danger:hover:not(:disabled) {
|
||||
background: var(--color-danger-soft);
|
||||
}
|
||||
|
||||
.button--ghost {
|
||||
border-color: var(--color-border);
|
||||
background: transparent;
|
||||
|
||||
Reference in New Issue
Block a user