Add server-side folders and files page UI (#6383)

This commit is contained in:
Anthony Stirling
2026-05-27 12:52:46 +01:00
committed by GitHub
parent 4564ed5bec
commit d42b779644
90 changed files with 14720 additions and 663 deletions
@@ -0,0 +1,147 @@
@folders @storage
Feature: Server-side folders and file placement
Verify the Phase A folder REST API and the file-folder placement
endpoint introduced alongside the new files page UI.
All folder endpoints require authentication. Each scenario starts
from a clean folder list so leftover state from previous runs cannot
trip the folder-cap or naming assertions.
Background:
Given I am logged in as admin
And I clear all my folders
# =========================================================================
# Folder CRUD
# =========================================================================
@positive @create
Scenario: Create a root folder
When I POST a folder with name "Invoices"
Then the response status code should be 201
And the response JSON folder should have name "Invoices"
And the response JSON folder.parentFolderId should be null
And the response JSON folder.createdAt should not be empty
@negative @create
Scenario: Cannot create a folder with a blank name
When I POST a folder with name " "
Then the response status code should be 400
@negative @create
Scenario: Cannot create a folder with an over-long name
When I POST a folder with a 256-character name
Then the response status code should be 400
@positive @rename
Scenario: Rename a folder
Given a folder "Old" exists
When I PATCH folder "Old" with name "New"
Then the response status code should be 200
And the response JSON folder should have name "New"
@negative @rename
Scenario: Renaming to a blank name is rejected
Given a folder "Keep" exists
When I PATCH folder "Keep" with name " "
Then the response status code should be 400
@positive @move
Scenario: Move a folder under a parent
Given a folder "Parent" exists
And a folder "Child" exists
When I PATCH folder "Child" with parentFolderId = "Parent.id"
Then the response status code should be 200
And the response JSON folder.parentFolderId should equal "Parent.id"
@positive @move
Scenario: Move a folder back to root by reparenting to null
Given a folder "Parent" exists
And a folder "Child" exists under "Parent"
When I PATCH folder "Child" with parentFolderId = "null"
Then the response status code should be 200
And the response JSON folder.parentFolderId should be null
@negative @cycle
Scenario: Self-parent is rejected
Given a folder "X" exists
When I PATCH folder "X" with parentFolderId = "X.id"
Then the response status code should be 400
@negative @cycle
Scenario: Moving a folder into its own subtree is rejected
Given a folder "A" exists
And a folder "B" exists under "A"
When I PATCH folder "A" with parentFolderId = "B.id"
Then the response status code should be 400
@positive @delete
Scenario: Deleting a folder cascades the subtree
Given a folder "Root" exists
And a folder "Child" exists under "Root"
When I DELETE folder "Root"
Then the response status code should be 200
When I list folders
Then the folder list should not contain "Root"
And the folder list should not contain "Child"
@positive @list
Scenario: List returns all my folders
Given a folder "Alpha" exists
And a folder "Beta" exists
When I list folders
Then the response status code should be 200
And the folder list should contain "Alpha"
And the folder list should contain "Beta"
# =========================================================================
# File - folder placement
# =========================================================================
@positive @file-placement
Scenario: Uploading a file lands at root by default
Given I upload a file as "sample.pdf"
Then the response status code should be 200
And the response JSON file.folderId should be null
@positive @file-placement
Scenario: Move a file into a folder
Given a folder "Target" exists
And I upload a file as "report.pdf"
When I PATCH file "report.pdf" with folderId = "Target.id"
Then the response status code should be 204
@positive @file-placement
Scenario: Move a file back to root via folderId = null
Given a folder "Target" exists
And I upload a file as "doc.pdf"
And I PATCH file "doc.pdf" with folderId = "Target.id"
When I PATCH file "doc.pdf" with folderId = "null"
Then the response status code should be 204
@positive @delete
Scenario: Deleting a folder reparents its files to root, never deletes them
Given a folder "Project" exists
And I upload a file as "kept.pdf"
And I PATCH file "kept.pdf" with folderId = "Project.id"
When I DELETE folder "Project"
Then the response status code should be 200
When I list files
Then the file list should contain a file named "kept.pdf" with folderId null
# =========================================================================
# Authentication
# =========================================================================
@negative @auth
Scenario: Folder list rejects unauthenticated requests
When I GET the folder list with no authentication
Then the response status code should be one of "401,403"
@negative @auth
Scenario: Folder create rejects unauthenticated requests
When I POST a folder named "Sneak" with no authentication
Then the response status code should be one of "401,403"
@@ -0,0 +1,401 @@
"""
Step definitions for the server-side folders feature
(folders_and_files.feature).
Covers:
- POST/PATCH/DELETE/GET /api/v1/storage/folders[/{id}]
- POST /api/v1/storage/files (multipart upload)
- PATCH /api/v1/storage/files/{id}/folder
- GET /api/v1/storage/files
Authentication: uses the JWT token stashed in context.jwt_token by the
existing auth step "Given I am logged in as admin".
State stashing per scenario:
context.folders_by_name -> { display_name: uuid }
context.uploaded_files -> { filename: numeric file id }
context.response -> the most recent requests.Response
The "Name.id" placeholder convention in the Gherkin lets us write
human-readable scenarios while resolving to real UUIDs at step time.
"""
import io
import requests
from behave import given, then, when
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
BASE_URL = "http://localhost:8080"
FOLDERS_URL = f"{BASE_URL}/api/v1/storage/folders"
FILES_URL = f"{BASE_URL}/api/v1/storage/files"
HTTP_TIMEOUT = 30
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _jwt_headers(context):
token = getattr(context, "jwt_token", None)
assert token, (
"No JWT token in context. Use 'Given I am logged in as admin' first."
)
return {"Authorization": f"Bearer {token}"}
def _json_jwt_headers(context):
return {**_jwt_headers(context), "Content-Type": "application/json"}
def _ensure_folders_dict(context):
if not hasattr(context, "folders_by_name"):
context.folders_by_name = {}
def _ensure_files_dict(context):
if not hasattr(context, "uploaded_files"):
context.uploaded_files = {}
def _resolve_folder_id(context, ref):
"""
Resolve a Gherkin placeholder to a folder id.
Conventions:
'null' -> Python None (means "no folder" / root)
'Name.id' -> uuid stashed under 'Name' in folders_by_name
anything else -> returned unchanged (raw UUID string)
"""
if ref is None or ref == "null":
return None
if ref.endswith(".id"):
name = ref[:-3]
_ensure_folders_dict(context)
assert name in context.folders_by_name, (
f"No folder named {name!r} stashed; available: "
f"{list(context.folders_by_name)}"
)
return context.folders_by_name[name]
return ref
def _generate_pdf_bytes():
buffer = io.BytesIO()
c = canvas.Canvas(buffer, pagesize=letter)
c.drawString(100, 700, "behave folder fixture")
c.showPage()
c.save()
return buffer.getvalue()
def _create_folder(context, name, parent_id=None):
payload = {"name": name}
if parent_id is not None:
payload["parentFolderId"] = parent_id
response = requests.post(
FOLDERS_URL,
headers=_json_jwt_headers(context),
json=payload,
timeout=HTTP_TIMEOUT,
)
return response
# ---------------------------------------------------------------------------
# GIVEN - setup
# ---------------------------------------------------------------------------
@given("I clear all my folders")
def step_clear_all_folders(context):
"""
Tear-down for prior runs. Folder DELETE cascades to children, so
a single pass over the top-level list is enough.
A 404 from the GET means the folder endpoint isn't deployed on this
build, which would render every scenario unrunnable - surface that
early as a clear assertion rather than letting individual steps fail
with cryptic errors.
"""
response = requests.get(
FOLDERS_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT
)
assert response.status_code in (200, 204), (
f"Folder list returned {response.status_code} during teardown - "
f"is the proprietary storage-folders module deployed? Body: "
f"{response.text[:300]}"
)
for folder in response.json() or []:
# Top-level folders only; cascade does the rest. Skipping children
# avoids 404s on the second pass when their parent has already gone.
if folder.get("parentFolderId") is None:
requests.delete(
f"{FOLDERS_URL}/{folder['id']}",
headers=_jwt_headers(context),
timeout=HTTP_TIMEOUT,
)
_ensure_folders_dict(context)
context.folders_by_name.clear()
@given('a folder "{name}" exists')
def step_folder_exists(context, name):
response = _create_folder(context, name)
assert response.status_code == 201, (
f"Could not create folder {name!r} during Given step: "
f"{response.status_code} {response.text}"
)
_ensure_folders_dict(context)
context.folders_by_name[name] = response.json()["id"]
context.response = response
@given('a folder "{name}" exists under "{parent}"')
def step_folder_exists_under(context, name, parent):
_ensure_folders_dict(context)
parent_id = context.folders_by_name.get(parent)
assert parent_id, (
f"Parent folder {parent!r} not created yet; available: "
f"{list(context.folders_by_name)}"
)
response = _create_folder(context, name, parent_id=parent_id)
assert response.status_code == 201, (
f"Could not create child folder {name!r}: "
f"{response.status_code} {response.text}"
)
context.folders_by_name[name] = response.json()["id"]
context.response = response
@given('I upload a file as "{filename}"')
@when('I upload a file as "{filename}"')
def step_upload_file(context, filename):
pdf_bytes = _generate_pdf_bytes()
response = requests.post(
FILES_URL,
headers=_jwt_headers(context), # multipart sets its own Content-Type
files={"file": (filename, pdf_bytes, "application/pdf")},
timeout=HTTP_TIMEOUT,
)
context.response = response
if response.status_code == 200:
_ensure_files_dict(context)
context.uploaded_files[filename] = response.json()["id"]
# ---------------------------------------------------------------------------
# WHEN - folder mutations
# ---------------------------------------------------------------------------
@when('I POST a folder with name "{name}"')
def step_post_folder(context, name):
response = _create_folder(context, name)
context.response = response
if response.status_code == 201:
_ensure_folders_dict(context)
context.folders_by_name[name] = response.json()["id"]
@when("I POST a folder with a 256-character name")
def step_post_oversize_folder(context):
"""256 chars trips the @Size(max = 255) validator on CreateFolderRequest."""
long_name = "x" * 256
response = _create_folder(context, long_name)
context.response = response
@when('I PATCH folder "{name}" with name "{new_name}"')
def step_patch_folder_name(context, name, new_name):
folder_id = _resolve_folder_id(context, f"{name}.id")
response = requests.patch(
f"{FOLDERS_URL}/{folder_id}",
headers=_json_jwt_headers(context),
json={"name": new_name},
timeout=HTTP_TIMEOUT,
)
context.response = response
if response.status_code == 200:
# Update the stash so later steps can still refer to it by either
# the old or new name in the same scenario.
context.folders_by_name[new_name] = folder_id
@when('I PATCH folder "{name}" with parentFolderId = "{parent_ref}"')
def step_patch_folder_parent(context, name, parent_ref):
folder_id = _resolve_folder_id(context, f"{name}.id")
parent_id = _resolve_folder_id(context, parent_ref)
# `reparent: true` is required - the UpdateFolderRequest treats a
# missing reparent flag as "do not change parent" even when
# parentFolderId is present. Sending the flag explicitly is what the
# service contract expects.
response = requests.patch(
f"{FOLDERS_URL}/{folder_id}",
headers=_json_jwt_headers(context),
json={"reparent": True, "parentFolderId": parent_id},
timeout=HTTP_TIMEOUT,
)
context.response = response
@when('I DELETE folder "{name}"')
def step_delete_folder(context, name):
folder_id = _resolve_folder_id(context, f"{name}.id")
response = requests.delete(
f"{FOLDERS_URL}/{folder_id}",
headers=_jwt_headers(context),
timeout=HTTP_TIMEOUT,
)
context.response = response
@when("I list folders")
def step_list_folders(context):
response = requests.get(
FOLDERS_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT
)
context.response = response
# ---------------------------------------------------------------------------
# WHEN - file - folder placement
# ---------------------------------------------------------------------------
@when('I PATCH file "{filename}" with folderId = "{folder_ref}"')
@given('I PATCH file "{filename}" with folderId = "{folder_ref}"')
def step_patch_file_folder(context, filename, folder_ref):
_ensure_files_dict(context)
file_id = context.uploaded_files.get(filename)
assert file_id, (
f"File {filename!r} not uploaded yet; available: "
f"{list(context.uploaded_files)}"
)
folder_id = _resolve_folder_id(context, folder_ref)
response = requests.patch(
f"{FILES_URL}/{file_id}/folder",
headers=_json_jwt_headers(context),
json={"folderId": folder_id},
timeout=HTTP_TIMEOUT,
)
context.response = response
@when("I list files")
def step_list_files(context):
response = requests.get(
FILES_URL, headers=_jwt_headers(context), timeout=HTTP_TIMEOUT
)
context.response = response
# ---------------------------------------------------------------------------
# WHEN - unauthenticated probes
# ---------------------------------------------------------------------------
@when("I GET the folder list with no authentication")
def step_get_folders_no_auth(context):
response = requests.get(FOLDERS_URL, timeout=HTTP_TIMEOUT)
context.response = response
@when('I POST a folder named "{name}" with no authentication')
def step_post_folder_no_auth(context, name):
response = requests.post(
FOLDERS_URL,
headers={"Content-Type": "application/json"},
json={"name": name},
timeout=HTTP_TIMEOUT,
)
context.response = response
# ---------------------------------------------------------------------------
# THEN - JSON shape assertions
# ---------------------------------------------------------------------------
@then('the response JSON folder should have name "{name}"')
def step_response_folder_name(context, name):
data = context.response.json()
assert data.get("name") == name, (
f"Expected name={name!r}, got {data.get('name')!r}. Body: {data}"
)
@then("the response JSON folder.parentFolderId should be null")
def step_response_folder_parent_null(context):
data = context.response.json()
assert data.get("parentFolderId") is None, (
f"Expected parentFolderId=null, got {data.get('parentFolderId')!r}"
)
@then('the response JSON folder.parentFolderId should equal "{ref}"')
def step_response_folder_parent_equal(context, ref):
data = context.response.json()
expected = _resolve_folder_id(context, ref)
assert data.get("parentFolderId") == expected, (
f"Expected parentFolderId={expected!r}, "
f"got {data.get('parentFolderId')!r}"
)
@then("the response JSON folder.createdAt should not be empty")
def step_response_folder_createdat_not_empty(context):
data = context.response.json()
assert data.get("createdAt"), (
f"Expected non-empty createdAt; body: {data}"
)
@then("the response JSON file.folderId should be null")
def step_response_file_folderid_null(context):
data = context.response.json()
assert data.get("folderId") is None, (
f"Expected folderId=null, got {data.get('folderId')!r}"
)
@then('the response JSON file.folderId should equal "{ref}"')
def step_response_file_folderid_equal(context, ref):
data = context.response.json()
expected = _resolve_folder_id(context, ref)
assert data.get("folderId") == expected, (
f"Expected folderId={expected!r}, got {data.get('folderId')!r}"
)
@then('the folder list should contain "{name}"')
def step_folder_list_contains(context, name):
folders = context.response.json()
names = [f.get("name") for f in folders]
assert name in names, f"Folder {name!r} not in list: {names}"
@then('the folder list should not contain "{name}"')
def step_folder_list_not_contains(context, name):
folders = context.response.json()
names = [f.get("name") for f in folders]
assert name not in names, (
f"Folder {name!r} should not be in list: {names}"
)
@then('the file list should contain a file named "{filename}" with folderId null')
def step_file_list_contains_root_file(context, filename):
files = context.response.json()
matches = [
f
for f in files
if f.get("fileName") == filename and f.get("folderId") is None
]
assert matches, (
f"No file named {filename!r} with folderId=null in list. "
f"Files seen: "
f"{[(f.get('fileName'), f.get('folderId')) for f in files]}"
)