mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-14 10:34:06 +02:00
Add server-side folders and files page UI (#6383)
This commit is contained in:
@@ -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]}"
|
||||
)
|
||||
Reference in New Issue
Block a user