49a5452141
This commit adds the first complete local-network deployment path for the project. It normalizes the runtime contract around a fixed container listener on 0.0.0.0:10000, binds the published compose port to 127.0.0.1, and keeps the image/build workflow aligned with the released container image. It also introduces an installation script, an nginx reverse-proxy template, and a safer SQLite backup flow based on sqlite3 .backup with retention and optional rclone upload support. Deployment-oriented configuration has been consolidated into .env.example, repository-local .env files are now ignored, and the deployment scripts are executable. In addition, the frontend mixed-content issue is fixed by switching the stylesheet reference to a root-relative static path, with tests updated to cover the regression. README guidance has been expanded to document the new install, nginx, backup, and restore conventions.
1013 lines
31 KiB
Python
1013 lines
31 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_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"
|