feat(m0): 后端骨架 + Web/安卓前端骨架 + 设计文档
- backend: FastAPI 应用工厂、统一错误信封、JWT、短信登录、工作区/成员/邀请、幂等中间件 - alembic 0001: workspace/member/invitation/audit_log/app_user + RLS 策略 + 双角色 GRANT - tests: 认证/工作区/租户隔离/幂等 四组用例(尚未在本机执行) - web: React18+Vite 骨架(登录、工作区、邀请落地页) - android: Compose 骨架(登录、工作区列表) - 2.Design: PRODUCT-PLAN / ROADMAP-M0-M1 / NAMING / DEVELOPMENT
This commit is contained in:
@@ -0,0 +1,175 @@
|
||||
"""工作区 / 成员 / 邀请。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from httpx import AsyncClient
|
||||
|
||||
from .conftest import auth, create_workspace, login, unwrap
|
||||
|
||||
|
||||
async def test_create_workspace_makes_creator_owner(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "达道科技")
|
||||
assert ws["role"] == "owner"
|
||||
assert ws["plan"] == "free"
|
||||
assert ws["slug"].startswith("ws-")
|
||||
|
||||
|
||||
async def test_workspace_appears_in_my_list_and_me(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "个人工作区")
|
||||
|
||||
listed = unwrap(await client.get("/api/v1/workspaces", headers=user["headers"]))
|
||||
assert [w["id"] for w in listed["items"]] == [ws["id"]]
|
||||
|
||||
me = unwrap(await client.get("/api/v1/auth/me", headers=user["headers"]))
|
||||
assert me["workspaces"][0]["role"] == "owner"
|
||||
|
||||
|
||||
async def test_members_list_contains_owner_with_masked_phone(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, "团队A")
|
||||
page = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/members", headers=user["headers"])
|
||||
)
|
||||
assert page["has_more"] is False
|
||||
member = page["items"][0]
|
||||
assert member["role"] == "owner"
|
||||
assert "****" in member["phone"]
|
||||
|
||||
|
||||
async def test_invite_and_join_flow(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
guest = await login(client)
|
||||
ws = await create_workspace(client, owner, "协作空间")
|
||||
|
||||
invite = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member", "expires_hours": 24},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert len(invite["code"]) == 8
|
||||
|
||||
joined = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"].lower()}, # 邀请码大小写不敏感
|
||||
headers=guest["headers"],
|
||||
)
|
||||
)
|
||||
assert joined["role"] == "member"
|
||||
|
||||
page = unwrap(
|
||||
await client.get(f"/api/v1/workspaces/{ws['id']}/members", headers=guest["headers"])
|
||||
)
|
||||
assert {m["role"] for m in page["items"]} == {"owner", "member"}
|
||||
|
||||
|
||||
async def test_invite_code_single_use(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
second = await login(client)
|
||||
third = await login(client)
|
||||
ws = await create_workspace(client, owner, "限量空间")
|
||||
invite = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"]},
|
||||
headers=second["headers"],
|
||||
)
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"]},
|
||||
headers=third["headers"],
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert resp.json()["code"] == 3010
|
||||
|
||||
|
||||
async def test_bad_invite_code_rejected(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
guest = await login(client)
|
||||
ws = await create_workspace(client, owner, "封闭空间")
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": "ZZZZZZZZ"},
|
||||
headers=guest["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 3010
|
||||
|
||||
|
||||
async def test_plain_member_cannot_invite(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
member = await login(client)
|
||||
ws = await create_workspace(client, owner, "权限空间")
|
||||
invite = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": invite["code"]},
|
||||
headers=member["headers"],
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "member"},
|
||||
headers=member["headers"],
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
assert resp.json()["code"] == 3004
|
||||
|
||||
|
||||
async def test_only_owner_can_invite_admin(client: AsyncClient) -> None:
|
||||
owner = await login(client)
|
||||
admin = await login(client)
|
||||
ws = await create_workspace(client, owner, "治理空间")
|
||||
|
||||
first = unwrap(
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "admin"},
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/join",
|
||||
json={"code": first["code"]},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
|
||||
resp = await client.post(
|
||||
f"/api/v1/workspaces/{ws['id']}/invitations",
|
||||
json={"role": "admin"},
|
||||
headers=admin["headers"],
|
||||
)
|
||||
assert resp.json()["code"] == 3004
|
||||
|
||||
|
||||
async def test_duplicate_slug_conflict(client: AsyncClient) -> None:
|
||||
user = await login(client)
|
||||
await client.post(
|
||||
"/api/v1/workspaces", json={"name": "占位", "slug": "taken"}, headers=user["headers"]
|
||||
)
|
||||
resp = await client.post(
|
||||
"/api/v1/workspaces", json={"name": "抢占", "slug": "taken"}, headers=user["headers"]
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
assert resp.json()["code"] == 1009
|
||||
|
||||
|
||||
async def test_requires_auth(client: AsyncClient) -> None:
|
||||
resp = await client.get("/api/v1/workspaces")
|
||||
assert resp.status_code == 401
|
||||
assert auth("bad.token.here") # 保证 helper 可用
|
||||
Reference in New Issue
Block a user