ed1e3311a5
test / pytest (push) Successful in 37s
- serve manifest and service worker from the app root for install compatibility - add manifest metadata, service worker registration, and Apple touch icon links to the base template - add install icon assets for Android, iOS, and desktop install flows - document deployment and validation notes for the new PWA support - cover the new endpoints and template output with tests
1046 lines
32 KiB
Python
1046 lines
32 KiB
Python
from io import BytesIO
|
|
from pathlib import Path
|
|
|
|
import app.db as db_module
|
|
import app.images as images_module
|
|
from PIL import Image
|
|
from fastapi import UploadFile
|
|
|
|
from app.models import Box, Item, SubItem
|
|
|
|
|
|
def create_box(client, name="Box A", note="Packed", room="Bedroom", status="ready", image=None):
|
|
data = {"name": name, "note": note, "room": room, "status": status}
|
|
files = {"image_file": image} if image is not None else None
|
|
return client.post("/boxes", data=data, files=files, follow_redirects=False)
|
|
|
|
|
|
def create_item(
|
|
client,
|
|
box_id,
|
|
name="Item A",
|
|
note="Note",
|
|
quantity="2",
|
|
is_container=False,
|
|
image=None,
|
|
submit_action="save",
|
|
):
|
|
data = {"name": name, "note": note, "quantity": quantity, "submit_action": submit_action}
|
|
if is_container:
|
|
data["is_container"] = "on"
|
|
files = {"image_file": image} if image is not None else None
|
|
return client.post(f"/boxes/{box_id}/items", data=data, files=files, follow_redirects=False)
|
|
|
|
|
|
def create_subitem(
|
|
client,
|
|
item_id,
|
|
name="SubItem A",
|
|
note="Small",
|
|
quantity="3",
|
|
image=None,
|
|
submit_action="save",
|
|
):
|
|
files = {"image_file": image} if image is not None else None
|
|
return client.post(
|
|
f"/items/{item_id}/subitems",
|
|
data={"name": name, "note": note, "quantity": quantity, "submit_action": submit_action},
|
|
files=files,
|
|
follow_redirects=False,
|
|
)
|
|
|
|
|
|
def make_image_upload(
|
|
filename="sample.png",
|
|
image_format="PNG",
|
|
size=(2000, 1000),
|
|
color=(255, 0, 0),
|
|
mode="RGB",
|
|
):
|
|
image = Image.new(mode, size, color)
|
|
output = BytesIO()
|
|
image.save(output, format=image_format)
|
|
return (filename, output.getvalue(), "image/png")
|
|
|
|
|
|
def make_oriented_jpeg_upload(
|
|
filename="portrait.jpg",
|
|
size=(1600, 900),
|
|
orientation=6,
|
|
):
|
|
image = Image.new("RGB", size, (20, 120, 220))
|
|
exif = Image.Exif()
|
|
exif[274] = orientation
|
|
output = BytesIO()
|
|
image.save(output, format="JPEG", exif=exif)
|
|
return (filename, output.getvalue(), "image/jpeg")
|
|
|
|
|
|
def read_jpeg_size(image_bytes):
|
|
with Image.open(BytesIO(image_bytes)) as image:
|
|
return image.format, image.size
|
|
|
|
|
|
def test_app_uses_isolated_sqlite_database(client):
|
|
assert db_module.engine is not None
|
|
|
|
database_name = Path(db_module.engine.url.database)
|
|
assert database_name.name == "test.db"
|
|
assert "data/app.db" not in str(database_name)
|
|
|
|
|
|
def test_root_redirects_to_boxes(client):
|
|
response = client.get("/", follow_redirects=False)
|
|
|
|
assert response.status_code == 302
|
|
assert response.headers["location"] == "/boxes"
|
|
|
|
|
|
def test_boxes_page_returns_200(client):
|
|
response = client.get("/boxes")
|
|
|
|
assert response.status_code == 200
|
|
assert "箱子" in response.text
|
|
|
|
|
|
def test_boxes_page_uses_relative_stylesheet_path(client):
|
|
response = client.get("/boxes")
|
|
|
|
assert response.status_code == 200
|
|
assert 'href="/static/style.css"' in response.text
|
|
assert "http://" not in response.text.split("/static/style.css")[0]
|
|
|
|
|
|
def test_base_template_includes_pwa_metadata(client):
|
|
response = client.get("/boxes")
|
|
|
|
assert response.status_code == 200
|
|
assert 'rel="manifest" href="/manifest.webmanifest"' in response.text
|
|
assert 'name="theme-color" content="#0b57d0"' in response.text
|
|
assert 'rel="apple-touch-icon" href="/static/icons/apple-touch-icon.png"' in response.text
|
|
assert 'navigator.serviceWorker.register("/service-worker.js")' in response.text
|
|
|
|
|
|
def test_manifest_is_served_with_pwa_fields(client):
|
|
response = client.get("/manifest.webmanifest")
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("application/manifest+json")
|
|
|
|
manifest = response.json()
|
|
assert manifest["name"] == "搬家助手"
|
|
assert manifest["short_name"] == "搬家助手"
|
|
assert manifest["start_url"] == "/boxes"
|
|
assert manifest["display"] == "standalone"
|
|
assert any(icon["sizes"] == "192x192" for icon in manifest["icons"])
|
|
assert any(icon["purpose"] == "maskable" for icon in manifest["icons"])
|
|
|
|
|
|
def test_service_worker_is_served_from_root(client):
|
|
response = client.get("/service-worker.js")
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"].startswith("application/javascript")
|
|
assert 'self.addEventListener("install"' in response.text
|
|
|
|
|
|
def test_boxes_overview_card_shows_note_and_item_count_without_room_or_status(client, db_session):
|
|
box = Box(
|
|
name="Kitchen Box",
|
|
note="易碎餐具和杯子",
|
|
room="Kitchen",
|
|
status="packed",
|
|
)
|
|
box.items.append(Item(name="Plate", is_container=False))
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.get("/boxes")
|
|
|
|
assert response.status_code == 200
|
|
assert "Kitchen Box" in response.text
|
|
assert "物品数:1" in response.text
|
|
assert "易碎餐具和杯子" in response.text
|
|
assert "房间:" not in response.text
|
|
assert "状态:" not in response.text
|
|
|
|
|
|
def test_boxes_overview_renders_cleanly_when_note_is_empty(client, db_session):
|
|
box = Box(name="No Note Box", note=None, room="Office", status="open")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.get("/boxes")
|
|
|
|
assert response.status_code == 200
|
|
assert "No Note Box" in response.text
|
|
assert "房间:" not in response.text
|
|
assert "状态:" not in response.text
|
|
|
|
|
|
def test_can_create_box(client, db_session):
|
|
response = create_box(client, name="Kitchen Box")
|
|
|
|
assert response.status_code == 303
|
|
|
|
box = db_session.query(Box).one()
|
|
assert box.name == "Kitchen Box"
|
|
assert box.room == "Bedroom"
|
|
|
|
|
|
def test_can_edit_box(client, db_session):
|
|
box = Box(name="Old Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.post(
|
|
f"/boxes/{box.id}/update",
|
|
data={"name": "Updated Box", "note": "Updated", "room": "Office", "status": "open"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
|
|
db_session.refresh(box)
|
|
assert box.name == "Updated Box"
|
|
assert box.room == "Office"
|
|
assert box.status == "open"
|
|
|
|
|
|
def test_can_delete_box(client, db_session):
|
|
box = Box(name="Delete Me")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
box_id = box.id
|
|
|
|
response = client.post(f"/boxes/{box_id}/delete", follow_redirects=False)
|
|
|
|
assert response.status_code == 303
|
|
db_session.expire_all()
|
|
assert db_session.get(Box, box_id) is None
|
|
|
|
|
|
def test_deleting_box_cascades_to_items_and_subitems(client, db_session):
|
|
box = Box(name="Cascade Box")
|
|
item = Item(name="Container", is_container=True, box=box)
|
|
subitem = SubItem(name="Cable", parent_item=item)
|
|
db_session.add_all([box, item, subitem])
|
|
db_session.commit()
|
|
|
|
response = client.post(f"/boxes/{box.id}/delete", follow_redirects=False)
|
|
|
|
assert response.status_code == 303
|
|
assert db_session.query(Box).count() == 0
|
|
assert db_session.query(Item).count() == 0
|
|
assert db_session.query(SubItem).count() == 0
|
|
|
|
|
|
def test_missing_box_returns_404(client):
|
|
response = client.get("/boxes/9999")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_box_detail_returns_200_when_box_exists(client, db_session):
|
|
box = Box(name="Visible Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.get(f"/boxes/{box.id}")
|
|
|
|
assert response.status_code == 200
|
|
assert "Visible Box" in response.text
|
|
|
|
|
|
def test_box_detail_item_cards_show_notes_without_note_placeholder_text(client, db_session):
|
|
box = Box(name="Overview Box")
|
|
box.items.append(Item(name="Accessory Pouch", note="充电器和转换头", is_container=True, quantity=2))
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.get(f"/boxes/{box.id}")
|
|
|
|
assert response.status_code == 200
|
|
assert "Accessory Pouch" in response.text
|
|
assert "数量:2" in response.text
|
|
assert "充电器和转换头" in response.text
|
|
assert "有备注" not in response.text
|
|
|
|
|
|
def test_can_create_regular_item_under_box(client, db_session):
|
|
box = Box(name="Main Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = create_item(client, box.id, name="Book", is_container=False)
|
|
|
|
assert response.status_code == 303
|
|
|
|
item = db_session.query(Item).one()
|
|
assert item.name == "Book"
|
|
assert item.is_container is False
|
|
|
|
|
|
def test_can_create_container_item_under_box(client, db_session):
|
|
box = Box(name="Main Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = create_item(client, box.id, name="Accessory Pouch", is_container=True)
|
|
|
|
assert response.status_code == 303
|
|
|
|
item = db_session.query(Item).one()
|
|
assert item.is_container is True
|
|
|
|
|
|
def test_can_edit_item(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Old Item", box=box, is_container=False)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = client.post(
|
|
f"/items/{item.id}/update",
|
|
data={"name": "New Item", "note": "Changed", "quantity": "7", "is_container": "on"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
|
|
db_session.refresh(item)
|
|
assert item.name == "New Item"
|
|
assert item.quantity == 7
|
|
assert item.is_container is True
|
|
|
|
|
|
def test_can_delete_item(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Delete Item", box=box, is_container=False)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
item_id = item.id
|
|
|
|
response = client.post(f"/items/{item_id}/delete", follow_redirects=False)
|
|
|
|
assert response.status_code == 303
|
|
db_session.expire_all()
|
|
assert db_session.get(Item, item_id) is None
|
|
|
|
|
|
def test_deleting_container_item_cascades_to_subitems(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Container", box=box, is_container=True)
|
|
subitem = SubItem(name="Clip", parent_item=item)
|
|
db_session.add_all([box, item, subitem])
|
|
db_session.commit()
|
|
|
|
response = client.post(f"/items/{item.id}/delete", follow_redirects=False)
|
|
|
|
assert response.status_code == 303
|
|
assert db_session.query(Item).count() == 0
|
|
assert db_session.query(SubItem).count() == 0
|
|
|
|
|
|
def test_missing_item_returns_404(client):
|
|
response = client.get("/items/9999")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_item_detail_returns_200_when_item_exists(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Visible Item", box=box, is_container=False)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = client.get(f"/items/{item.id}")
|
|
|
|
assert response.status_code == 200
|
|
assert "Visible Item" in response.text
|
|
|
|
|
|
def test_can_create_subitem_under_container_item(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Pouch", box=box, is_container=True)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = create_subitem(client, item.id, name="USB Cable")
|
|
|
|
assert response.status_code == 303
|
|
|
|
subitem = db_session.query(SubItem).one()
|
|
assert subitem.name == "USB Cable"
|
|
|
|
|
|
def test_cannot_create_subitem_under_non_container_item(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Book", box=box, is_container=False)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = create_subitem(client, item.id, name="Should Fail")
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == "Only container items can have sub-items"
|
|
assert db_session.query(SubItem).count() == 0
|
|
|
|
|
|
def test_can_edit_subitem(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Pouch", box=box, is_container=True)
|
|
subitem = SubItem(name="Old Sub-item", parent_item=item)
|
|
db_session.add_all([box, item, subitem])
|
|
db_session.commit()
|
|
|
|
response = client.post(
|
|
f"/subitems/{subitem.id}/update",
|
|
data={"name": "New Sub-item", "note": "Updated", "quantity": "9"},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
|
|
db_session.refresh(subitem)
|
|
assert subitem.name == "New Sub-item"
|
|
assert subitem.quantity == 9
|
|
|
|
|
|
def test_can_delete_subitem(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Pouch", box=box, is_container=True)
|
|
subitem = SubItem(name="Delete Sub-item", parent_item=item)
|
|
db_session.add_all([box, item, subitem])
|
|
db_session.commit()
|
|
subitem_id = subitem.id
|
|
|
|
response = client.post(f"/subitems/{subitem_id}/delete", follow_redirects=False)
|
|
|
|
assert response.status_code == 303
|
|
db_session.expire_all()
|
|
assert db_session.get(SubItem, subitem_id) is None
|
|
|
|
|
|
def test_missing_subitem_returns_404(client):
|
|
response = client.get("/subitems/9999/edit")
|
|
|
|
assert response.status_code == 404
|
|
|
|
|
|
def test_post_redirects_are_reasonable(client, db_session):
|
|
box = Box(name="Redirect Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
item_response = create_item(client, box.id, name="Lamp")
|
|
item = db_session.query(Item).one()
|
|
item.is_container = True
|
|
db_session.commit()
|
|
|
|
subitem_response = create_subitem(client, item.id, name="Bulb")
|
|
|
|
assert item_response.headers["location"] == f"/boxes/{box.id}"
|
|
assert subitem_response.headers["location"] == f"/items/{item.id}"
|
|
|
|
|
|
def test_can_upload_image_for_box_and_process_it(client, db_session):
|
|
response = create_box(client, name="Photo Box", image=make_image_upload())
|
|
|
|
assert response.status_code == 303
|
|
|
|
box = db_session.query(Box).one()
|
|
assert box.image_blob is not None
|
|
assert box.image_mime_type == "image/jpeg"
|
|
assert box.image_width == 1600
|
|
assert box.image_height == 800
|
|
|
|
image_format, image_size = read_jpeg_size(box.image_blob)
|
|
assert image_format == "JPEG"
|
|
assert image_size == (1600, 800)
|
|
|
|
|
|
def test_image_pipeline_applies_exif_orientation_before_saving(client, db_session):
|
|
response = create_box(client, name="Portrait Box", image=make_oriented_jpeg_upload())
|
|
|
|
assert response.status_code == 303
|
|
|
|
box = db_session.query(Box).filter_by(name="Portrait Box").one()
|
|
assert box.image_blob is not None
|
|
assert box.image_width == 900
|
|
assert box.image_height == 1600
|
|
|
|
image_format, image_size = read_jpeg_size(box.image_blob)
|
|
assert image_format == "JPEG"
|
|
assert image_size == (900, 1600)
|
|
|
|
|
|
def test_can_upload_image_for_item(client, db_session):
|
|
box = Box(name="Main Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = create_item(client, box.id, name="Book", image=make_image_upload())
|
|
|
|
assert response.status_code == 303
|
|
|
|
item = db_session.query(Item).one()
|
|
assert item.image_blob is not None
|
|
assert item.image_mime_type == "image/jpeg"
|
|
|
|
|
|
def test_can_upload_image_for_subitem(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Pouch", box=box, is_container=True)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = create_subitem(client, item.id, name="Cable", image=make_image_upload())
|
|
|
|
assert response.status_code == 303
|
|
|
|
subitem = db_session.query(SubItem).one()
|
|
assert subitem.image_blob is not None
|
|
assert subitem.image_mime_type == "image/jpeg"
|
|
|
|
|
|
def test_box_image_route_returns_jpeg(client, db_session):
|
|
box = Box(name="Photo Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
client.post(
|
|
f"/boxes/{box.id}/update",
|
|
data={"name": box.name, "note": "", "room": "", "status": ""},
|
|
files={"image_file": make_image_upload()},
|
|
follow_redirects=False,
|
|
)
|
|
db_session.expire_all()
|
|
|
|
response = client.get(f"/boxes/{box.id}/image")
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "image/jpeg"
|
|
|
|
|
|
def test_item_image_route_returns_jpeg(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Lamp", box=box, is_container=False)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
client.post(
|
|
f"/items/{item.id}/update",
|
|
data={"name": item.name, "note": "", "quantity": "", "is_container": ""},
|
|
files={"image_file": make_image_upload()},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
response = client.get(f"/items/{item.id}/image")
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "image/jpeg"
|
|
|
|
|
|
def test_subitem_image_route_returns_jpeg(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Pouch", box=box, is_container=True)
|
|
subitem = SubItem(name="Cable", parent_item=item)
|
|
db_session.add_all([box, item, subitem])
|
|
db_session.commit()
|
|
|
|
client.post(
|
|
f"/subitems/{subitem.id}/update",
|
|
data={"name": subitem.name, "note": "", "quantity": ""},
|
|
files={"image_file": make_image_upload()},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
response = client.get(f"/subitems/{subitem.id}/image")
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers["content-type"] == "image/jpeg"
|
|
|
|
|
|
def test_image_routes_return_404_when_no_image(client, db_session):
|
|
box = Box(name="Main Box")
|
|
item = Item(name="Lamp", box=box, is_container=False)
|
|
subitem = SubItem(name="Cable", parent_item=Item(name="Pouch", box=box, is_container=True))
|
|
db_session.add_all([box, item, subitem.parent_item, subitem])
|
|
db_session.commit()
|
|
|
|
assert client.get(f"/boxes/{box.id}/image").status_code == 404
|
|
assert client.get(f"/items/{item.id}/image").status_code == 404
|
|
assert client.get(f"/subitems/{subitem.id}/image").status_code == 404
|
|
|
|
|
|
def test_image_routes_return_404_for_missing_objects(client):
|
|
assert client.get("/boxes/9999/image").status_code == 404
|
|
assert client.get("/items/9999/image").status_code == 404
|
|
assert client.get("/subitems/9999/image").status_code == 404
|
|
|
|
|
|
def test_can_replace_existing_image(client, db_session):
|
|
box = Box(name="Photo Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
client.post(
|
|
f"/boxes/{box.id}/update",
|
|
data={"name": box.name, "note": "", "room": "", "status": ""},
|
|
files={"image_file": make_image_upload(color=(255, 0, 0))},
|
|
follow_redirects=False,
|
|
)
|
|
db_session.expire_all()
|
|
first_blob = db_session.get(Box, box.id).image_blob
|
|
|
|
client.post(
|
|
f"/boxes/{box.id}/update",
|
|
data={"name": box.name, "note": "", "room": "", "status": ""},
|
|
files={"image_file": make_image_upload(color=(0, 255, 0))},
|
|
follow_redirects=False,
|
|
)
|
|
db_session.expire_all()
|
|
updated_box = db_session.get(Box, box.id)
|
|
|
|
assert updated_box.image_blob is not None
|
|
assert updated_box.image_blob != first_blob
|
|
|
|
|
|
def test_can_delete_existing_image_and_clear_metadata(client, db_session):
|
|
box = Box(name="Photo Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
client.post(
|
|
f"/boxes/{box.id}/update",
|
|
data={"name": box.name, "note": "", "room": "", "status": ""},
|
|
files={"image_file": make_image_upload()},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
response = client.post(f"/boxes/{box.id}/image/delete", follow_redirects=False)
|
|
|
|
assert response.status_code == 303
|
|
db_session.expire_all()
|
|
updated_box = db_session.get(Box, box.id)
|
|
assert updated_box.image_blob is None
|
|
assert updated_box.image_mime_type is None
|
|
assert updated_box.image_width is None
|
|
assert updated_box.image_height is None
|
|
assert client.get(f"/boxes/{box.id}/image").status_code == 404
|
|
|
|
|
|
def test_deleting_image_when_none_exists_is_safe(client, db_session):
|
|
item = Item(name="No Image Item", box=Box(name="Main Box"), is_container=False)
|
|
db_session.add(item)
|
|
db_session.commit()
|
|
|
|
response = client.post(f"/items/{item.id}/image/delete", follow_redirects=False)
|
|
|
|
assert response.status_code == 303
|
|
|
|
|
|
def test_uploading_non_image_file_returns_400_and_does_not_write_data(client, db_session):
|
|
box = Box(name="Main Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.post(
|
|
f"/items/{box.id}/subitems",
|
|
data={"name": "Should Fail", "note": "", "quantity": ""},
|
|
files={"image_file": ("bad.txt", b"not an image", "text/plain")},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code in {400, 404}
|
|
|
|
item = Item(name="Pouch", box=box, is_container=True)
|
|
db_session.add(item)
|
|
db_session.commit()
|
|
|
|
response = create_subitem(
|
|
client,
|
|
item.id,
|
|
name="Should Fail",
|
|
image=("bad.txt", b"not an image", "text/plain"),
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["detail"] == "上传的文件不是合法图片"
|
|
assert db_session.query(SubItem).count() == 0
|
|
|
|
|
|
def test_broken_image_processing_returns_400_and_keeps_image_fields_empty(client, db_session):
|
|
box = Box(name="Main Box")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.post(
|
|
f"/boxes/{box.id}/update",
|
|
data={"name": box.name, "note": "", "room": "", "status": ""},
|
|
files={"image_file": ("broken.jpg", b"\xff\xd8\xff\xdbbroken", "image/jpeg")},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
db_session.expire_all()
|
|
updated_box = db_session.get(Box, box.id)
|
|
assert updated_box.image_blob is None
|
|
assert updated_box.image_mime_type is None
|
|
assert updated_box.image_width is None
|
|
assert updated_box.image_height is None
|
|
|
|
|
|
def test_heic_upload_returns_clear_error_if_heif_support_is_unavailable(monkeypatch):
|
|
heic_file = UploadFile(filename="sample.heic", file=BytesIO(b"not-a-real-heic"))
|
|
|
|
def fake_open(*args, **kwargs):
|
|
raise images_module.UnidentifiedImageError("cannot identify image file")
|
|
|
|
monkeypatch.setattr(images_module, "HEIF_SUPPORT_ENABLED", False)
|
|
monkeypatch.setattr(images_module.Image, "open", fake_open)
|
|
monkeypatch.setattr(images_module.shutil, "which", lambda command: None)
|
|
|
|
try:
|
|
images_module.process_upload(heic_file)
|
|
assert False, "Expected HEIC upload to raise HTTPException"
|
|
except Exception as exc:
|
|
assert getattr(exc, "status_code", None) == 400
|
|
assert "HEIC/HEIF" in getattr(exc, "detail", "")
|
|
|
|
|
|
def test_can_search_box_by_name(client, db_session):
|
|
box = Box(name="冬季衣物箱")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.get("/search?q=衣物")
|
|
|
|
assert response.status_code == 200
|
|
assert "冬季衣物箱" in response.text
|
|
assert "类型:Box" in response.text
|
|
|
|
|
|
def test_can_search_box_by_note(client, db_session):
|
|
box = Box(name="普通箱子", note="里面放厨房锅具")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.get("/search?q=锅具")
|
|
|
|
assert response.status_code == 200
|
|
assert "普通箱子" in response.text
|
|
|
|
|
|
def test_can_search_item_by_name(client, db_session):
|
|
box = Box(name="书房箱")
|
|
item = Item(name="电源延长线", box=box, is_container=False)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = client.get("/search?q=延长线")
|
|
|
|
assert response.status_code == 200
|
|
assert "电源延长线" in response.text
|
|
assert "位于箱子:书房箱" in response.text
|
|
|
|
|
|
def test_can_search_item_by_note(client, db_session):
|
|
box = Box(name="工具箱")
|
|
item = Item(name="杂项", note="放备用螺丝刀", box=box, is_container=False)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = client.get("/search?q=螺丝刀")
|
|
|
|
assert response.status_code == 200
|
|
assert "杂项" in response.text
|
|
|
|
|
|
def test_can_search_subitem_by_name(client, db_session):
|
|
box = Box(name="文件箱")
|
|
item = Item(name="文件袋", box=box, is_container=True)
|
|
subitem = SubItem(name="护照复印件", parent_item=item)
|
|
db_session.add_all([box, item, subitem])
|
|
db_session.commit()
|
|
|
|
response = client.get("/search?q=护照")
|
|
|
|
assert response.status_code == 200
|
|
assert "护照复印件" in response.text
|
|
assert "位于物品:文件袋 / 箱子:文件箱" in response.text
|
|
|
|
|
|
def test_can_search_subitem_by_note(client, db_session):
|
|
box = Box(name="电子箱")
|
|
item = Item(name="配件盒", box=box, is_container=True)
|
|
subitem = SubItem(name="接口", note="备用转接头", parent_item=item)
|
|
db_session.add_all([box, item, subitem])
|
|
db_session.commit()
|
|
|
|
response = client.get("/search?q=转接头")
|
|
|
|
assert response.status_code == 200
|
|
assert "接口" in response.text
|
|
|
|
|
|
def test_search_result_shows_item_box_info(client, db_session):
|
|
box = Box(name="客厅箱")
|
|
item = Item(name="相机", box=box, is_container=True)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = client.get("/search?q=相机")
|
|
|
|
assert response.status_code == 200
|
|
assert "位于箱子:客厅箱" in response.text
|
|
assert "容器" in response.text
|
|
assert "是" in response.text
|
|
|
|
|
|
def test_search_result_shows_subitem_item_and_box_info(client, db_session):
|
|
box = Box(name="卧室箱")
|
|
item = Item(name="收纳袋", box=box, is_container=True)
|
|
subitem = SubItem(name="袜子", parent_item=item)
|
|
db_session.add_all([box, item, subitem])
|
|
db_session.commit()
|
|
|
|
response = client.get("/search?q=袜子")
|
|
|
|
assert response.status_code == 200
|
|
assert "位于物品:收纳袋 / 箱子:卧室箱" in response.text
|
|
|
|
|
|
def test_search_page_handles_empty_query(client):
|
|
response = client.get("/search")
|
|
|
|
assert response.status_code == 200
|
|
assert "输入关键词后,可以跨 Box、Item、SubItem 进行搜索。" in response.text
|
|
|
|
|
|
def test_search_page_handles_no_results(client):
|
|
response = client.get("/search?q=不存在的关键词")
|
|
|
|
assert response.status_code == 200
|
|
assert "没有找到匹配结果。" in response.text
|
|
|
|
|
|
def test_search_result_renders_thumbnail_link_when_image_exists(client, db_session):
|
|
box = Box(name="图片箱")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
client.post(
|
|
f"/boxes/{box.id}/update",
|
|
data={"name": box.name, "note": "", "room": "", "status": ""},
|
|
files={"image_file": make_image_upload()},
|
|
follow_redirects=False,
|
|
)
|
|
|
|
response = client.get("/search?q=图片箱")
|
|
|
|
assert response.status_code == 200
|
|
assert f'/boxes/{box.id}/image' in response.text
|
|
|
|
|
|
def test_search_result_without_image_does_not_break_template(client, db_session):
|
|
item = Item(name="无图物品", box=Box(name="普通箱"), is_container=False)
|
|
db_session.add(item)
|
|
db_session.commit()
|
|
|
|
response = client.get("/search?q=无图物品")
|
|
|
|
assert response.status_code == 200
|
|
assert "无图物品" in response.text
|
|
|
|
|
|
def test_new_box_page_shows_clear_context(client):
|
|
response = client.get("/boxes/new")
|
|
|
|
assert response.status_code == 200
|
|
assert "新建 Box" in response.text
|
|
assert "创建顶层箱子" in response.text
|
|
|
|
|
|
def test_new_item_page_shows_clear_context_and_default_quantity(client, db_session):
|
|
box = Box(name="主卧箱")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.get(f"/boxes/{box.id}/items/new")
|
|
|
|
assert response.status_code == 200
|
|
assert "新建 Item" in response.text
|
|
assert "主卧箱" in response.text
|
|
assert 'name="quantity"' in response.text
|
|
assert 'value="1"' in response.text
|
|
assert "这个物品本身是一个小容器" in response.text
|
|
assert "保存并添加下一个" in response.text
|
|
|
|
|
|
def test_new_subitem_page_shows_clear_context_and_default_quantity(client, db_session):
|
|
box = Box(name="客厅箱")
|
|
item = Item(name="文件袋", box=box, is_container=True)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = client.get(f"/items/{item.id}/subitems/new")
|
|
|
|
assert response.status_code == 200
|
|
assert "新建 SubItem" in response.text
|
|
assert "客厅箱" in response.text
|
|
assert "文件袋" in response.text
|
|
assert 'name="quantity"' in response.text
|
|
assert 'value="1"' in response.text
|
|
assert "保存并添加下一个" in response.text
|
|
|
|
|
|
def test_box_detail_page_renders_clear_hierarchy_and_dense_list_structure(client, db_session):
|
|
box = Box(name="厨房箱")
|
|
item = Item(name="锅", box=box, is_container=False)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = client.get(f"/boxes/{box.id}")
|
|
|
|
assert response.status_code == 200
|
|
assert "Box" in response.text
|
|
assert "厨房箱" in response.text
|
|
assert "overview-grid" in response.text
|
|
assert f'data-href="/items/{item.id}"' in response.text
|
|
assert "是否容器" not in response.text
|
|
|
|
|
|
def test_item_detail_page_renders_clear_hierarchy(client, db_session):
|
|
box = Box(name="书房箱")
|
|
item = Item(name="配件盒", box=box, is_container=True)
|
|
subitem = SubItem(name="转接头", parent_item=item)
|
|
db_session.add_all([box, item, subitem])
|
|
db_session.commit()
|
|
|
|
response = client.get(f"/items/{item.id}")
|
|
|
|
assert response.status_code == 200
|
|
assert "容器型 Item" in response.text
|
|
assert "书房箱" in response.text
|
|
assert "SubItem" in response.text
|
|
assert f'data-href="/subitems/{subitem.id}/edit"' in response.text
|
|
assert "overview-grid" in response.text
|
|
|
|
|
|
def test_box_detail_page_shows_primary_and_secondary_cta_buttons(client, db_session):
|
|
box = Box(name="操作箱")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.get(f"/boxes/{box.id}")
|
|
|
|
assert response.status_code == 200
|
|
assert "button button-primary" in response.text
|
|
assert "button button-secondary" in response.text
|
|
assert "删除箱子" in response.text
|
|
|
|
|
|
def test_boxes_overview_uses_clickable_cards_without_detail_edit_buttons(client, db_session):
|
|
box = Box(name="可点击箱子")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = client.get("/boxes")
|
|
|
|
assert response.status_code == 200
|
|
assert f'data-href="/boxes/{box.id}"' in response.text
|
|
assert "查看详情" not in response.text
|
|
assert "编辑</a>" not in response.text
|
|
|
|
|
|
def test_item_detail_page_shows_primary_action_for_adding_subitems(client, db_session):
|
|
box = Box(name="容器箱")
|
|
item = Item(name="收纳盒", box=box, is_container=True)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = client.get(f"/items/{item.id}")
|
|
|
|
assert response.status_code == 200
|
|
assert "添加子物品" in response.text
|
|
assert "button button-primary" in response.text
|
|
|
|
|
|
def test_creating_regular_item_redirects_back_to_parent_box(client, db_session):
|
|
box = Box(name="连续录入箱")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = create_item(client, box.id, name="剪刀", is_container=False)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == f"/boxes/{box.id}"
|
|
|
|
|
|
def test_creating_regular_subitem_redirects_back_to_parent_item(client, db_session):
|
|
box = Box(name="配件箱")
|
|
item = Item(name="线材袋", box=box, is_container=True)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = create_subitem(client, item.id, name="USB头")
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == f"/items/{item.id}"
|
|
|
|
|
|
def test_creating_box_redirects_to_new_box_detail(client):
|
|
response = create_box(client, name="新箱子")
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"].startswith("/boxes/")
|
|
assert not response.headers["location"].endswith("/items/new")
|
|
|
|
|
|
def test_creating_container_item_redirects_to_item_detail(client, db_session):
|
|
box = Box(name="子容器箱")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = create_item(client, box.id, name="小收纳盒", is_container=True)
|
|
|
|
created_item = db_session.query(Item).one()
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == f"/items/{created_item.id}"
|
|
|
|
|
|
def test_creating_item_with_save_and_add_next_returns_to_same_new_item_context(client, db_session):
|
|
box = Box(name="快速录入箱")
|
|
db_session.add(box)
|
|
db_session.commit()
|
|
|
|
response = create_item(
|
|
client,
|
|
box.id,
|
|
name="袜子",
|
|
is_container=False,
|
|
submit_action="save_and_add_next",
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == f"/boxes/{box.id}/items/new"
|
|
|
|
|
|
def test_creating_subitem_with_save_and_add_next_returns_to_same_new_subitem_context(client, db_session):
|
|
box = Box(name="电子箱")
|
|
item = Item(name="配件袋", box=box, is_container=True)
|
|
db_session.add_all([box, item])
|
|
db_session.commit()
|
|
|
|
response = create_subitem(
|
|
client,
|
|
item.id,
|
|
name="转接头",
|
|
submit_action="save_and_add_next",
|
|
)
|
|
|
|
assert response.status_code == 303
|
|
assert response.headers["location"] == f"/items/{item.id}/subitems/new"
|