from io import BytesIO from pathlib import Path import app.db as db_module from PIL import Image 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, ): data = {"name": name, "note": note, "quantity": quantity} 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): 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}, 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 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_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_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_id = int(item_response.headers["location"].split("/")[-1]) item = db_session.get(Item, item_id) item.is_container = True db_session.commit() subitem_response = create_subitem(client, item.id, name="Bulb") assert item_response.headers["location"] == f"/items/{item.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_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