Skip to content

Commit a41bfa1

Browse files
committed
test(admin): add foreign key fixtures and CRUD/filter regressions
1 parent 74880f4 commit a41bfa1

2 files changed

Lines changed: 137 additions & 38 deletions

File tree

tests/conftest.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,9 @@
88
import pytest
99
from fastapi import FastAPI
1010
from fastapi.testclient import TestClient
11-
from sqlalchemy import create_engine, Column, Integer, String, Boolean
11+
from sqlalchemy import create_engine, Column, Integer, String, Boolean, ForeignKey
1212
from sqlalchemy.ext.declarative import declarative_base
13-
from sqlalchemy.orm import sessionmaker, Session
13+
from sqlalchemy.orm import sessionmaker, Session, relationship
1414

1515
from internal_admin import AdminSite, AdminConfig, ModelAdmin
1616
from internal_admin.auth.models import AdminUser
@@ -30,6 +30,10 @@ class TestUser(Base):
3030
is_active = Column(Boolean, default=True)
3131
is_superuser = Column(Boolean, default=False)
3232

33+
@property
34+
def display_name(self) -> str:
35+
return self.username or f"User {self.id}"
36+
3337

3438
class TestModel(Base):
3539
"""Simple test model for admin testing."""
@@ -41,13 +45,51 @@ class TestModel(Base):
4145
is_active = Column(Boolean, default=True)
4246

4347

48+
class TestCategory(Base):
49+
"""Related model for foreign key tests."""
50+
__tablename__ = "test_categories"
51+
52+
id = Column(Integer, primary_key=True)
53+
name = Column(String(100), nullable=False)
54+
55+
products = relationship("TestProduct", back_populates="category")
56+
57+
def __str__(self) -> str:
58+
return self.name
59+
60+
61+
class TestProduct(Base):
62+
"""Model containing a foreign key for admin tests."""
63+
__tablename__ = "test_products"
64+
65+
id = Column(Integer, primary_key=True)
66+
name = Column(String(100), nullable=False)
67+
category_id = Column(Integer, ForeignKey("test_categories.id"), nullable=False)
68+
is_active = Column(Boolean, default=True)
69+
70+
category = relationship("TestCategory", back_populates="products")
71+
72+
4473
class TestModelAdmin(ModelAdmin):
4574
"""Test ModelAdmin configuration."""
4675
list_display = ["id", "name", "is_active"]
4776
search_fields = ["name", "description"]
4877
list_filter = ["is_active"]
4978

5079

80+
class TestCategoryAdmin(ModelAdmin):
81+
"""Admin configuration for category model."""
82+
list_display = ["id", "name"]
83+
search_fields = ["name"]
84+
85+
86+
class TestProductAdmin(ModelAdmin):
87+
"""Admin configuration for product model."""
88+
list_display = ["id", "name", "category_id", "is_active"]
89+
search_fields = ["name"]
90+
list_filter = ["category_id", "is_active"]
91+
92+
5193
@pytest.fixture(scope="session")
5294
def test_db() -> Generator[str, None, None]:
5395
"""Create a temporary test database."""
@@ -87,6 +129,8 @@ def admin_site(admin_config: AdminConfig) -> AdminSite:
87129
# Create fresh AdminSite after clearing registry
88130
site = AdminSite(admin_config)
89131
site.register(TestModel, TestModelAdmin)
132+
site.register(TestCategory, TestCategoryAdmin)
133+
site.register(TestProduct, TestProductAdmin)
90134
return site
91135

92136

@@ -150,6 +194,29 @@ def test_objects(db_session: Session) -> list[TestModel]:
150194
return objects
151195

152196

197+
@pytest.fixture
198+
def fk_objects(db_session: Session) -> dict[str, Any]:
199+
"""Create related objects for foreign key tests."""
200+
categories = [
201+
TestCategory(name="Hardware"),
202+
TestCategory(name="Software"),
203+
]
204+
db_session.add_all(categories)
205+
db_session.flush()
206+
207+
products = [
208+
TestProduct(name="Keyboard", category_id=categories[0].id, is_active=True),
209+
TestProduct(name="IDE License", category_id=categories[1].id, is_active=True),
210+
]
211+
db_session.add_all(products)
212+
db_session.commit()
213+
214+
return {
215+
"categories": categories,
216+
"products": products,
217+
}
218+
219+
153220
@pytest.fixture
154221
def authenticated_client(client: TestClient, test_user: TestUser) -> TestClient:
155222
"""Create authenticated test client."""

tests/test_admin.py

Lines changed: 68 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,35 +10,33 @@
1010

1111
class TestAdminSite:
1212
"""Test AdminSite functionality."""
13-
13+
1414
def test_admin_site_creation(self, admin_config: AdminConfig):
1515
"""Test AdminSite can be created."""
1616
site = AdminSite(admin_config)
1717
assert site.config == admin_config
1818
assert not site._initialized
19-
19+
2020
def test_model_registration(self, admin_site: AdminSite):
2121
"""Test model registration works."""
2222
from tests.conftest import TestModel, TestUser
2323
assert admin_site.is_registered(TestModel)
2424
assert not admin_site.is_registered(TestUser)
25-
25+
2626
def test_get_registered_models(self, admin_site: AdminSite):
2727
"""Test getting registered models."""
2828
models = admin_site.get_registered_models()
2929
assert len(models) == 1
30-
# Check that TestModel is registered (by class name since import paths may differ)
3130
model_names = [model.__name__ for model in models.keys()]
3231
assert "TestModel" in model_names
3332

3433

3534
class TestAdminConfig:
3635
"""Test AdminConfig functionality."""
37-
36+
3837
def test_config_validation(self, test_db: str):
3938
"""Test config validation."""
4039
from tests.conftest import TestUser
41-
# Valid config should work
4240
config = AdminConfig(
4341
database_url=test_db,
4442
secret_key="test-key",
@@ -47,7 +45,7 @@ def test_config_validation(self, test_db: str):
4745
assert config.database_url == test_db
4846
assert config.is_sqlite
4947
assert not config.is_postgresql
50-
48+
5149
def test_invalid_config(self):
5250
"""Test invalid config raises errors."""
5351
from tests.conftest import TestUser
@@ -57,7 +55,7 @@ def test_invalid_config(self):
5755
secret_key="test-key",
5856
user_model=TestUser
5957
)
60-
58+
6159
with pytest.raises(ValueError, match="secret_key is required"):
6260
AdminConfig(
6361
database_url="sqlite:///test.db",
@@ -68,47 +66,46 @@ def test_invalid_config(self):
6866

6967
class TestModelAdmin:
7068
"""Test ModelAdmin functionality."""
71-
69+
7270
def test_model_admin_creation(self):
7371
"""Test ModelAdmin can be created."""
7472
from tests.conftest import TestModel
7573
admin = ModelAdmin(TestModel)
7674
assert admin.model == TestModel
77-
75+
7876
def test_list_display(self):
7977
"""Test list_display configuration."""
8078
from tests.conftest import TestModel
8179
admin = ModelAdmin(TestModel)
82-
83-
# Should return default columns if not configured
80+
8481
display_fields = admin.get_list_display()
8582
assert "id" in display_fields
8683
assert "name" in display_fields
87-
84+
8885
def test_search_fields(self):
8986
"""Test search_fields configuration."""
9087
from tests.conftest import TestModel
9188
admin = ModelAdmin(TestModel)
9289
admin.search_fields = ["name"]
93-
90+
9491
search_fields = admin.get_search_fields()
9592
assert search_fields == ["name"]
9693

9794

9895
class TestAdminRoutes:
9996
"""Test admin route functionality."""
100-
97+
10198
def test_dashboard_requires_auth(self, client: TestClient):
10299
"""Test dashboard requires authentication."""
103100
response = client.get("/admin/")
104101
assert response.status_code == 401
105-
102+
106103
def test_login_page(self, client: TestClient):
107104
"""Test login page is accessible."""
108105
response = client.get("/admin/login")
109106
assert response.status_code == 200
110107
assert "login" in response.text.lower()
111-
108+
112109
def test_model_list_requires_auth(self, client: TestClient):
113110
"""Test model list requires authentication."""
114111
response = client.get("/admin/testmodel/")
@@ -117,24 +114,24 @@ def test_model_list_requires_auth(self, client: TestClient):
117114

118115
class TestAuthentication:
119116
"""Test authentication functionality."""
120-
117+
121118
def test_successful_login(self, client: TestClient, test_user):
122119
"""Test successful login."""
123120
response = client.post("/admin/login", data={
124121
"username": test_user.username,
125122
"password": "testpass123"
126123
})
127-
assert response.status_code == 302 # Redirect after login
128-
124+
assert response.status_code == 302
125+
129126
def test_invalid_login(self, client: TestClient):
130127
"""Test invalid login credentials."""
131128
response = client.post("/admin/login", data={
132129
"username": "nonexistent",
133130
"password": "wrongpass"
134131
})
135-
assert response.status_code == 302 # Redirect to login with error
132+
assert response.status_code == 302
136133
assert "error=invalid_credentials" in response.headers["location"]
137-
134+
138135
def test_authenticated_dashboard_access(self, authenticated_client: TestClient):
139136
"""Test authenticated users can access dashboard."""
140137
response = authenticated_client.get("/admin/")
@@ -144,36 +141,36 @@ def test_authenticated_dashboard_access(self, authenticated_client: TestClient):
144141

145142
class TestCRUDOperations:
146143
"""Test CRUD operations via admin interface."""
147-
144+
148145
def test_model_list_view(self, authenticated_client: TestClient, test_objects):
149146
"""Test model list view."""
150147
response = authenticated_client.get("/admin/testmodel/")
151148
assert response.status_code == 200
152149
assert "Test 1" in response.text
153150
assert "Test 2" in response.text
154-
151+
155152
def test_create_form_view(self, authenticated_client: TestClient):
156153
"""Test create form view."""
157154
response = authenticated_client.get("/admin/testmodel/create/")
158155
assert response.status_code == 200
159156
assert "form" in response.text.lower()
160157
assert "name" in response.text.lower()
161-
158+
162159
def test_create_object(self, authenticated_client: TestClient):
163160
"""Test creating new object."""
164161
response = authenticated_client.post("/admin/testmodel/create/", data={
165162
"name": "New Test Object",
166163
"description": "Created via test",
167164
"is_active": True
168165
})
169-
assert response.status_code == 302 # Redirect after create
170-
166+
assert response.status_code == 302
167+
171168
def test_edit_form_view(self, authenticated_client: TestClient, test_objects):
172169
"""Test edit form view."""
173170
response = authenticated_client.get(f"/admin/testmodel/{test_objects[0].id}/")
174171
assert response.status_code == 200
175172
assert test_objects[0].name in response.text
176-
173+
177174
def test_delete_confirmation(self, authenticated_client: TestClient, test_objects):
178175
"""Test delete confirmation page."""
179176
response = authenticated_client.get(f"/admin/testmodel/{test_objects[0].id}/delete/")
@@ -184,29 +181,64 @@ def test_delete_confirmation(self, authenticated_client: TestClient, test_object
184181

185182
class TestSearchAndFiltering:
186183
"""Test search and filtering functionality."""
187-
184+
188185
def test_search(self, authenticated_client: TestClient, test_objects):
189186
"""Test search functionality."""
190187
response = authenticated_client.get("/admin/testmodel/?search=Test 1")
191188
assert response.status_code == 200
192189
assert "Test 1" in response.text
193-
190+
194191
def test_filter(self, authenticated_client: TestClient, test_objects):
195192
"""Test filtering functionality."""
196193
response = authenticated_client.get("/admin/testmodel/?is_active=true")
197194
assert response.status_code == 200
198-
# Should show active objects but not inactive ones
199195

200196

201197
class TestPermissions:
202198
"""Test permission system."""
203-
199+
204200
def test_superuser_permissions(self, authenticated_client: TestClient):
205201
"""Test superuser has all permissions."""
206-
# Should be able to access model list
207202
response = authenticated_client.get("/admin/testmodel/")
208203
assert response.status_code == 200
209-
210-
# Should be able to access create form
204+
211205
response = authenticated_client.get("/admin/testmodel/create/")
212-
assert response.status_code == 200
206+
assert response.status_code == 200
207+
208+
209+
class TestForeignKeys:
210+
"""Test foreign key functionality in forms and filters."""
211+
212+
def test_fk_field_renders_select_choices(self, authenticated_client: TestClient, fk_objects):
213+
"""Create form should render related model choices for FK fields."""
214+
response = authenticated_client.get("/admin/testproduct/create/")
215+
assert response.status_code == 200
216+
assert "category_id" in response.text
217+
assert "Hardware" in response.text
218+
assert "Software" in response.text
219+
220+
def test_fk_create_submission_persists_relation(self, authenticated_client: TestClient, db_session, fk_objects):
221+
"""Submitting FK value should be validated and persisted correctly."""
222+
from tests.conftest import TestProduct
223+
224+
hardware_id = fk_objects["categories"][0].id
225+
response = authenticated_client.post("/admin/testproduct/create/", data={
226+
"name": "Mouse",
227+
"category_id": str(hardware_id),
228+
"is_active": "true",
229+
})
230+
231+
assert response.status_code == 302
232+
233+
created = db_session.query(TestProduct).filter(TestProduct.name == "Mouse").first()
234+
assert created is not None
235+
assert created.category_id == hardware_id
236+
237+
def test_fk_filter_applies_correctly(self, authenticated_client: TestClient, fk_objects):
238+
"""FK list filter should match rows by related ID."""
239+
software_id = fk_objects["categories"][1].id
240+
response = authenticated_client.get(f"/admin/testproduct/?category_id={software_id}")
241+
242+
assert response.status_code == 200
243+
assert "IDE License" in response.text
244+
assert "Keyboard" not in response.text

0 commit comments

Comments
 (0)