Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ def list_project_previews(
) -> dict[int, str | None]:
"""一次查询当前页项目的资产预览,结果包含所有传入项目 ID。"""

@abstractmethod
def rename_project(
self, session: Session, project: Project, *, project_name: str
) -> Project:
"""修改已完成归属校验的项目名称。"""

@abstractmethod
def delete_project(self, session: Session, project_id: int) -> bool:
"""删除项目并返回是否找到。"""
7 changes: 7 additions & 0 deletions backend/packages/app/src/windup_app/server/project/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,13 @@ def list_project_previews(
)
return previews

def rename_project(
self, session: Session, project: Project, *, project_name: str
) -> Project:
project.project_name = project_name
session.flush()
return project

def delete_project(self, session: Session, project_id: int) -> bool:
project = session.get(Project, project_id)
if project is None:
Expand Down
35 changes: 35 additions & 0 deletions backend/packages/app/src/windup_app/web/api/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,12 @@ class ProjectCreate(BaseModel):
sprite_sample_url: str | None = None


class ProjectRename(BaseModel):
"""重命名项目请求。"""

project_name: str = Field(min_length=1, max_length=20)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Reject whitespace-only project names

min_length=1 只检查字符串长度,因此 PATCH 请求可以提交全是空格的 project_name,并将这个不可见名称持久化到数据库;这绕过了项目名称应为 1–20 个有效字符的验收约束。前端弹窗会先 trim(),但直接 API 调用和其他客户端仍可触发该路径。请在后端对名称做 trim 后的非空校验(并保持保存值与前端行为一致),或使用等效的约束。



class ProjectOut(BaseModel):
"""项目响应。"""

Expand Down Expand Up @@ -129,6 +135,35 @@ def get_project(
return Response.success(ProjectOut.model_validate(project))


@router.patch("/{project_id}", response_model=Response[ProjectOut])
def rename_project(
project_id: int,
body: ProjectRename,
request: Request,
session: Session = Depends(get_session),
) -> Response[ProjectOut]:
user_id = request.state.current_user.id
project = service.get_project(session, project_id, for_update=True)
if project is None or project.user_id != user_id:
raise BizException("项目不存在", code=BizCode.NOT_FOUND)
if project.project_name == body.project_name:
return Response.success(
ProjectOut.model_validate(project), message="重命名成功"
)
if service.project_name_exists(
session, user_id=user_id, project_name=body.project_name
):
raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST)
try:
project = service.rename_project(
session, project, project_name=body.project_name
)
except IntegrityError:
session.rollback()
raise BizException("项目名称已存在", code=BizCode.BAD_REQUEST) from None
return Response.success(ProjectOut.model_validate(project), message="重命名成功")


@router.delete("/{project_id}", response_model=Response[None])
def delete_project(
project_id: int,
Expand Down
52 changes: 52 additions & 0 deletions backend/tests/test_project_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -227,6 +227,58 @@ def record_character_select(
assert len(statements) == 1


# -- PATCH /projects/{id} ----------------------------------------------------


def test_rename_success_persists_the_new_name(auth_client):
created = auth_client.post(
"/projects", json=_payload(project_name="重命名前")
).json()["data"]

resp = auth_client.patch(
f"/projects/{created['id']}", json={"project_name": "重命名后"}
)

body = resp.json()
assert body["code"] == 200
assert body["message"] == "重命名成功"
assert body["data"]["project_name"] == "重命名后"
persisted = auth_client.get(f"/projects/{created['id']}").json()["data"]
assert persisted["project_name"] == "重命名后"


def test_rename_duplicate_name_returns_400(auth_client):
auth_client.post("/projects", json=_payload(project_name="已存在"))
created = auth_client.post(
"/projects", json=_payload(project_name="待修改")
).json()["data"]

resp = auth_client.patch(
f"/projects/{created['id']}", json={"project_name": "已存在"}
)

assert resp.json()["code"] == 400
assert resp.json()["message"] == "项目名称已存在"
persisted = auth_client.get(f"/projects/{created['id']}").json()["data"]
assert persisted["project_name"] == "待修改"


def test_rename_rejects_another_users_project(auth_client, auth_client_b):
created = auth_client.post(
"/projects", json=_payload(project_name="我的项目")
).json()["data"]

resp = auth_client_b.patch(
f"/projects/{created['id']}", json={"project_name": "越权改名"}
)

assert resp.json()["code"] == 404
assert (
auth_client.get(f"/projects/{created['id']}").json()["data"]["project_name"]
== "我的项目"
)


# -- DELETE /projects/{id} ---------------------------------------------------


Expand Down
23 changes: 23 additions & 0 deletions frontend/src/entities/project/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,25 @@ describe('projectApis', () => {
expect(requestUrl).toBe('https://api.windup.test/projects/42')
})

it('renames one Project through the backend resource path', async () => {
let request: Request | undefined
const renamedDto = { ...projectDto, project_name: '新项目名' }
const { projectApis } = await loadProjectApis(async (input, init) => {
request = new Request(input, init)
return jsonResponse(renamedDto)
})

await expect(projectApis.rename('42', '新项目名')).resolves.toMatchObject({
id: '42',
name: '新项目名',
})
expect(request?.url).toBe('https://api.windup.test/projects/42')
expect(request?.method).toBe('PATCH')
await expect(request?.json()).resolves.toEqual({
project_name: '新项目名',
})
})

it('uses the access-token provider registered at the shared HTTP boundary', async () => {
let authorization: string | null = null
const { projectApis } = await loadProjectApis(async (input, init) => {
Expand Down Expand Up @@ -160,6 +179,10 @@ describe('projectApis', () => {
spriteSize: { width: 256, height: 256 },
}),
).rejects.toBeInstanceOf(ProjectNameConflictError)

await expect(projectApis.rename('42', '点灯人')).rejects.toBeInstanceOf(
ProjectNameConflictError,
)
})

it('maps the backend in-use project contract to a stable domain error', async () => {
Expand Down
24 changes: 23 additions & 1 deletion frontend/src/entities/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,12 @@ export const DIRECTIONAL_MOVEMENT: Record<DirectionalMovement, string> = {
'eight-way': '八向',
}

/** Project 对应的一组后端接口。PR #75 未提供更新端点,因此这里不声明 update。 */
/** Project 对应的一组后端接口。 */
export interface ProjectApis {
list(query?: ProjectPageQuery): Promise<Paged<Project>>
get(id: Project['id']): Promise<Project>
create(input: CreateProjectInput): Promise<Project>
rename(id: Project['id'], name: string): Promise<Project>
remove(id: Project['id']): Promise<void>
}

Expand Down Expand Up @@ -218,6 +219,27 @@ export const projectApis: ProjectApis = {
}
},

async rename(id, name) {
try {
return mapProject(
await getApiClient().request<ProjectDto>(`/projects/${encodeURIComponent(id)}`, {
method: 'PATCH',
json: { project_name: name },
}),
)
} catch (error) {
if (
error instanceof ApiError &&
error.kind === 'business' &&
error.code === 400 &&
error.message === '项目名称已存在'
) {
throw new ProjectNameConflictError({ cause: error })
}
throw error
}
},

async remove(id) {
try {
await getApiClient().request<null>(`/projects/${encodeURIComponent(id)}`, {
Expand Down
53 changes: 51 additions & 2 deletions frontend/src/pages/projects/index.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,53 @@ describe('ProjectsPage', () => {
).toHaveLength(0)
})

it('keeps project actions discoverable and persists a renamed project', async () => {
const backend = installBackend()
render(
<AuthenticatedAuthSession>
<MemoryRouter initialEntries={['/projects']}>
<AppRoutes />
</MemoryRouter>
</AuthenticatedAuthSession>,
)

expect(await screen.findAllByRole('link', { name: /打开项目/ })).toHaveLength(2)
const actions = screen.getByRole('button', { name: '项目操作 空白海岸' })
expect(actions.getAttribute('aria-expanded')).toBe('false')

fireEvent.click(actions)

expect(actions.getAttribute('aria-expanded')).toBe('true')
expect(screen.getByRole('group', { name: '空白海岸的项目操作' })).toBeTruthy()

fireEvent.keyDown(document, { key: 'Escape' })

expect(actions.getAttribute('aria-expanded')).toBe('false')
expect(screen.queryByRole('group', { name: '空白海岸的项目操作' })).toBeNull()

fireEvent.click(actions)
const actionsGroup = screen.getByRole('group', {
name: '空白海岸的项目操作',
})
fireEvent.click(within(actionsGroup).getByRole('button', { name: '重命名项目' }))

const dialog = screen.getByRole('dialog', { name: '重命名项目' })
const nameInput = within(dialog).getByRole('textbox', { name: '项目名称' })
expect(nameInput.getAttribute('value')).toBe('空白海岸')
fireEvent.change(nameInput, { target: { value: '雾港' } })
fireEvent.click(within(dialog).getByRole('button', { name: '保存名称' }))

expect(await screen.findByRole('link', { name: '打开项目 雾港' })).toBeTruthy()
expect(screen.queryByRole('dialog', { name: '重命名项目' })).toBeNull()
const renameRequest = backend.requests.find(
(request) => request.method === 'PATCH' && request.url.endsWith('/projects/99'),
)
expect(renameRequest).toBeTruthy()
await expect(renameRequest?.json()).resolves.toEqual({
project_name: '雾港',
})
})

it('sends creation to the project create page and deletes through the Project API', async () => {
const backend = installBackend()
render(
Expand All @@ -217,7 +264,8 @@ describe('ProjectsPage', () => {
)
expect(screen.queryByRole('dialog', { name: '新建项目' })).toBeNull()

fireEvent.click(screen.getByRole('button', { name: '删除项目 空白海岸' }))
fireEvent.click(screen.getByRole('button', { name: '项目操作 空白海岸' }))
fireEvent.click(screen.getByRole('button', { name: '删除项目' }))
fireEvent.click(screen.getByRole('button', { name: '确认删除项目' }))

await waitFor(() => {
Expand All @@ -243,7 +291,8 @@ describe('ProjectsPage', () => {
)

expect(await screen.findByRole('link', { name: '打开项目 点灯人 · MVP' })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: '删除项目 点灯人 · MVP' }))
fireEvent.click(screen.getByRole('button', { name: '项目操作 点灯人 · MVP' }))
fireEvent.click(screen.getByRole('button', { name: '删除项目' }))
fireEvent.click(screen.getByRole('button', { name: '确认删除项目' }))

expect(await screen.findByText('项目下仍有角色,无法删除')).toBeTruthy()
Expand Down
Loading
Loading