format
This commit is contained in:
parent
f4ad388e36
commit
59c08ecb84
20 changed files with 66 additions and 55 deletions
|
|
@ -36,6 +36,7 @@ class TokenResponse(ApiModel):
|
||||||
token_type: str = "bearer"
|
token_type: str = "bearer"
|
||||||
user: User
|
user: User
|
||||||
|
|
||||||
|
|
||||||
PBKDF2_ALG = "pbkdf2_sha256"
|
PBKDF2_ALG = "pbkdf2_sha256"
|
||||||
PBKDF2_ITER = 390000 # similar to Django default; adjust in settings if needed
|
PBKDF2_ITER = 390000 # similar to Django default; adjust in settings if needed
|
||||||
SALT_BYTES = 16
|
SALT_BYTES = 16
|
||||||
|
|
@ -91,7 +92,9 @@ def _token_pair_for_user(user: User) -> tuple[str, str]:
|
||||||
|
|
||||||
|
|
||||||
@router.post("/register", response_model=TokenResponse, operation_id="register")
|
@router.post("/register", response_model=TokenResponse, operation_id="register")
|
||||||
async def register(request: Request, body: RegisterBody, conn: aiosqlite.Connection = Depends(get_db)):
|
async def register(
|
||||||
|
request: Request, body: RegisterBody, conn: aiosqlite.Connection = Depends(get_db)
|
||||||
|
):
|
||||||
existing = await users_db.get_by_email(conn, body.email)
|
existing = await users_db.get_by_email(conn, body.email)
|
||||||
if existing:
|
if existing:
|
||||||
return error_response(request, 400, "Email already registered")
|
return error_response(request, 400, "Email already registered")
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,9 @@ def slugify(name: str) -> str:
|
||||||
|
|
||||||
@router.get("/users/me/households", response_model=List[HouseholdResponse])
|
@router.get("/users/me/households", response_model=List[HouseholdResponse])
|
||||||
async def list_my_households(
|
async def list_my_households(
|
||||||
request: Request, user: User = Depends(get_current_user), conn: aiosqlite.Connection = Depends(get_db)
|
request: Request,
|
||||||
|
user: User = Depends(get_current_user),
|
||||||
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
):
|
):
|
||||||
results: list[HouseholdResponse] = []
|
results: list[HouseholdResponse] = []
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
|
|
|
||||||
|
|
@ -48,7 +48,7 @@ async def get_upcoming_meals_scoped(
|
||||||
# Temporary path: direct query with household_id filter
|
# Temporary path: direct query with household_id filter
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {','.join(meals.Meal.KEYS)} FROM Meal
|
SELECT {",".join(meals.Meal.KEYS)} FROM Meal
|
||||||
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL AND household_id = ?
|
WHERE suggested_date >= ? AND suggested_date <= ? AND consumed_date IS NULL AND deleted_date IS NULL AND household_id = ?
|
||||||
""",
|
""",
|
||||||
(date_from, to, hid),
|
(date_from, to, hid),
|
||||||
|
|
|
||||||
|
|
@ -162,16 +162,15 @@ def extend_with_problem_and_cookie_auth(app: FastAPI) -> None:
|
||||||
if isinstance(store, dict) and store.get("$ref") == "#/components/schemas/StoreEnum":
|
if isinstance(store, dict) and store.get("$ref") == "#/components/schemas/StoreEnum":
|
||||||
props["storeName"] = {"$ref": "#/components/schemas/StoreNameOut"}
|
props["storeName"] = {"$ref": "#/components/schemas/StoreNameOut"}
|
||||||
|
|
||||||
# Mark bearer security for v2 routes we know require auth
|
# Mark bearer security for v2 routes we know require auth
|
||||||
# Simple heuristic: underline select paths under /api/v1/users/me and /api/v1/households/* that are protected
|
# Simple heuristic: underline select paths under /api/v1/users/me and /api/v1/households/* that are protected
|
||||||
for path, ops in paths.items():
|
for path, ops in paths.items():
|
||||||
if not isinstance(path, str) or not path.startswith("/api/v1/"):
|
if not isinstance(path, str) or not path.startswith("/api/v1/"):
|
||||||
continue
|
continue
|
||||||
if not isinstance(ops, dict):
|
if not isinstance(ops, dict):
|
||||||
continue
|
continue
|
||||||
needs_bearer = (
|
needs_bearer = path.startswith("/api/v1/users/me/") or path.startswith(
|
||||||
path.startswith("/api/v1/users/me/")
|
"/api/v1/households/"
|
||||||
or path.startswith("/api/v1/households/")
|
|
||||||
)
|
)
|
||||||
if not needs_bearer:
|
if not needs_bearer:
|
||||||
continue
|
continue
|
||||||
|
|
|
||||||
|
|
@ -68,13 +68,17 @@ async def list_recipes(
|
||||||
for r in items:
|
for r in items:
|
||||||
r.ingredients = by_recipe.get(r.id, [])
|
r.ingredients = by_recipe.get(r.id, [])
|
||||||
next_cursor = str(items[-1].id) if has_more and items else None
|
next_cursor = str(items[-1].id) if has_more and items else None
|
||||||
total = await (recipes.count_by_name_scoped(conn, q, hid) if q else recipes.count_all_scoped(conn, hid))
|
total = await (
|
||||||
|
recipes.count_by_name_scoped(conn, q, hid) if q else recipes.count_all_scoped(conn, hid)
|
||||||
|
)
|
||||||
return Page(items=items, nextCursor=next_cursor, prevCursor=None, total=total)
|
return Page(items=items, nextCursor=next_cursor, prevCursor=None, total=total)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}})
|
@router.get("/{recipe_id}", response_model=RecipeOut, responses={404: {"model": ProblemDetails}})
|
||||||
async def get_recipe(
|
async def get_recipe(
|
||||||
recipe_id: int, household=Depends(get_household_from_slug), conn: aiosqlite.Connection = Depends(get_db)
|
recipe_id: int,
|
||||||
|
household=Depends(get_household_from_slug),
|
||||||
|
conn: aiosqlite.Connection = Depends(get_db),
|
||||||
):
|
):
|
||||||
r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"])
|
r = await recipes.find_recipe_by_id_scoped(conn, recipe_id, household["id"])
|
||||||
if not r:
|
if not r:
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,16 @@ router = APIRouter(prefix="/shopping", tags=["shopping"])
|
||||||
|
|
||||||
|
|
||||||
class CurrentShoppingList(ApiModel):
|
class CurrentShoppingList(ApiModel):
|
||||||
outstanding_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
outstanding_items: List[ListIngredientItem] = Field(
|
||||||
requested_meals: List[RequestedMealItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
min_length=0, json_schema_extra={"minItems": 0}
|
||||||
|
)
|
||||||
|
requested_meals: List[RequestedMealItem] = Field(
|
||||||
|
min_length=0, json_schema_extra={"minItems": 0}
|
||||||
|
)
|
||||||
# Make all collections required to avoid undefined/null semantics in clients
|
# Make all collections required to avoid undefined/null semantics in clients
|
||||||
purchased_items: List[ListIngredientItem] = Field(min_length=0, json_schema_extra={"minItems": 0})
|
purchased_items: List[ListIngredientItem] = Field(
|
||||||
|
min_length=0, json_schema_extra={"minItems": 0}
|
||||||
|
)
|
||||||
|
|
||||||
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
ingredients_lookup: Dict[int, ingredients.Ingredient]
|
||||||
meals_lookup: Dict[int, meals.Meal]
|
meals_lookup: Dict[int, meals.Meal]
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,9 @@ async def get_current_shopping_list_scoped(
|
||||||
# Add any additional items from shopping lists to the existing lookups
|
# Add any additional items from shopping lists to the existing lookups
|
||||||
additional_items = [item for sl in other_lists_domain.values() for item in sl.items]
|
additional_items = [item for sl in other_lists_domain.values() for item in sl.items]
|
||||||
if additional_items:
|
if additional_items:
|
||||||
await shopping.to_lookups(conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup)
|
await shopping.to_lookups(
|
||||||
|
conn, additional_items, meals_lookup, recipes_lookup, ingredients_lookup
|
||||||
|
)
|
||||||
|
|
||||||
shopping_list_lookup = {k: _to_shopping_list_out(v) for k, v in other_lists_domain.items()}
|
shopping_list_lookup = {k: _to_shopping_list_out(v) for k, v in other_lists_domain.items()}
|
||||||
|
|
||||||
|
|
|
||||||
2
db.py
2
db.py
|
|
@ -23,6 +23,7 @@ async def create(conn: aiosqlite.Connection):
|
||||||
# New v2 domain tables (users/households). Keep persons for compatibility during migration.
|
# New v2 domain tables (users/households). Keep persons for compatibility during migration.
|
||||||
try:
|
try:
|
||||||
import users.repository as users_db
|
import users.repository as users_db
|
||||||
|
|
||||||
await users_db.create(conn)
|
await users_db.create(conn)
|
||||||
except Exception:
|
except Exception:
|
||||||
# Be tolerant if table already exists or module missing in some setups
|
# Be tolerant if table already exists or module missing in some setups
|
||||||
|
|
@ -30,6 +31,7 @@ async def create(conn: aiosqlite.Connection):
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import households.repository as households_db
|
import households.repository as households_db
|
||||||
|
|
||||||
await households_db.create(conn)
|
await households_db.create(conn)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
|
||||||
|
|
@ -42,9 +42,7 @@ async def create(conn):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Indices
|
# Indices
|
||||||
await conn.execute(
|
await conn.execute("CREATE INDEX IF NOT EXISTS idx_household_slug ON Household(slug);")
|
||||||
"CREATE INDEX IF NOT EXISTS idx_household_slug ON Household(slug);"
|
|
||||||
)
|
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"CREATE INDEX IF NOT EXISTS idx_household_member_household ON HouseholdMember(household_id);"
|
"CREATE INDEX IF NOT EXISTS idx_household_member_household ON HouseholdMember(household_id);"
|
||||||
)
|
)
|
||||||
|
|
|
||||||
4
main.py
4
main.py
|
|
@ -98,7 +98,9 @@ async def request_validation_exc_handler(request: Request, exc: Exception):
|
||||||
errors.setdefault(loc, []).append(e.get("msg"))
|
errors.setdefault(loc, []).append(e.get("msg"))
|
||||||
# Special-case: Missing user_id cookie on POST /api/v1/shopping should be 401
|
# Special-case: Missing user_id cookie on POST /api/v1/shopping should be 401
|
||||||
try:
|
try:
|
||||||
is_shopping_post = request.method.upper() == "POST" and request.url.path == "/api/v1/shopping"
|
is_shopping_post = (
|
||||||
|
request.method.upper() == "POST" and request.url.path == "/api/v1/shopping"
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
is_shopping_post = False
|
is_shopping_post = False
|
||||||
if is_shopping_post:
|
if is_shopping_post:
|
||||||
|
|
|
||||||
|
|
@ -108,9 +108,7 @@ async def find_recipe_by_id(conn, recipe_id: int) -> Optional[Recipe]:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
async def find_recipe_by_id_scoped(
|
async def find_recipe_by_id_scoped(conn, recipe_id: int, household_id: int) -> Optional[Recipe]:
|
||||||
conn, recipe_id: int, household_id: int
|
|
||||||
) -> Optional[Recipe]:
|
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
SELECT {",".join(Recipe.KEYS)} FROM Recipe
|
SELECT {",".join(Recipe.KEYS)} FROM Recipe
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,9 @@ def _encode_payload(claims: Dict[str, Any]) -> str:
|
||||||
return _b64url_encode(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
|
return _b64url_encode(json.dumps(claims, separators=(",", ":")).encode("utf-8"))
|
||||||
|
|
||||||
|
|
||||||
def create_jwt(config: JwtConfig, subject: str, kind: str = "access", extra: Dict[str, Any] | None = None) -> str:
|
def create_jwt(
|
||||||
|
config: JwtConfig, subject: str, kind: str = "access", extra: Dict[str, Any] | None = None
|
||||||
|
) -> str:
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
ttl = config.access_ttl_seconds if kind == "access" else config.refresh_ttl_seconds
|
ttl = config.access_ttl_seconds if kind == "access" else config.refresh_ttl_seconds
|
||||||
secret = config.access_secret if kind == "access" else config.refresh_secret
|
secret = config.access_secret if kind == "access" else config.refresh_secret
|
||||||
|
|
@ -64,7 +66,9 @@ def create_jwt(config: JwtConfig, subject: str, kind: str = "access", extra: Dic
|
||||||
return f"{header}.{payload}.{signature}"
|
return f"{header}.{payload}.{signature}"
|
||||||
|
|
||||||
|
|
||||||
def verify_jwt(config: JwtConfig, token: str, expected_kind: str = "access") -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
def verify_jwt(
|
||||||
|
config: JwtConfig, token: str, expected_kind: str = "access"
|
||||||
|
) -> Tuple[Dict[str, Any], Dict[str, Any]]:
|
||||||
try:
|
try:
|
||||||
header_b64, payload_b64, sig = token.split(".")
|
header_b64, payload_b64, sig = token.split(".")
|
||||||
except ValueError:
|
except ValueError:
|
||||||
|
|
|
||||||
|
|
@ -430,9 +430,7 @@ async def load_shopping_list(conn, id: int) -> Optional[ShoppingList]:
|
||||||
return shopping_list
|
return shopping_list
|
||||||
|
|
||||||
|
|
||||||
async def load_shopping_list_scoped(
|
async def load_shopping_list_scoped(conn, id: int, household_id: int) -> Optional[ShoppingList]:
|
||||||
conn, id: int, household_id: int
|
|
||||||
) -> Optional[ShoppingList]:
|
|
||||||
shopping_list: Optional[ShoppingList] = None
|
shopping_list: Optional[ShoppingList] = None
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
f"""
|
f"""
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,7 @@ class TestHouseholdScoping(unittest.IsolatedAsyncioTestCase):
|
||||||
assert r.status_code in (403, 404) # may be 404 if default household missing
|
assert r.status_code in (403, 404) # may be 404 if default household missing
|
||||||
|
|
||||||
def test_scoped_whoami_ok_after_creating_household(self):
|
def test_scoped_whoami_ok_after_creating_household(self):
|
||||||
r = self.client.post(
|
r = self.client.post("/api/v1/households", json={"name": "Family"}, headers=self.headers)
|
||||||
"/api/v1/households", json={"name": "Family"}, headers=self.headers
|
|
||||||
)
|
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
slug = r.json()["slug"]
|
slug = r.json()["slug"]
|
||||||
r = self.client.get(f"/api/v1/households/{slug}/whoami", headers=self.headers)
|
r = self.client.get(f"/api/v1/households/{slug}/whoami", headers=self.headers)
|
||||||
|
|
|
||||||
|
|
@ -95,16 +95,12 @@ class TestMealsConsumedV2(unittest.IsolatedAsyncioTestCase):
|
||||||
assert meal["consumedDate"] is not None
|
assert meal["consumedDate"] is not None
|
||||||
|
|
||||||
# H1 meal request should be gone; H2 remains
|
# H1 meal request should be gone; H2 remains
|
||||||
r1 = self.client.get(
|
r1 = self.client.get(f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers)
|
||||||
f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers
|
|
||||||
)
|
|
||||||
assert r1.status_code == 200
|
assert r1.status_code == 200
|
||||||
cur1 = r1.json()
|
cur1 = r1.json()
|
||||||
assert all(i.get("mealId") != self.meal_h1 for i in cur1["requestedMeals"]) # none for h1
|
assert all(i.get("mealId") != self.meal_h1 for i in cur1["requestedMeals"]) # none for h1
|
||||||
|
|
||||||
r2 = self.client.get(
|
r2 = self.client.get(f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers)
|
||||||
f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers
|
|
||||||
)
|
|
||||||
assert r2.status_code == 200
|
assert r2.status_code == 200
|
||||||
cur2 = r2.json()
|
cur2 = r2.json()
|
||||||
assert any(i.get("mealId") == self.meal_h2 for i in cur2["requestedMeals"]) # still present
|
assert any(i.get("mealId") == self.meal_h2 for i in cur2["requestedMeals"]) # still present
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,14 @@ def test_migration_adds_tables_and_columns_and_ports_data(tmp_path):
|
||||||
await run_migration(conn)
|
await run_migration(conn)
|
||||||
|
|
||||||
# Verify new tables exist
|
# Verify new tables exist
|
||||||
for tbl in ["User", "LocalCredentials", "OAuthCredentials", "Household", "HouseholdMember", "HouseholdInvitation"]:
|
for tbl in [
|
||||||
|
"User",
|
||||||
|
"LocalCredentials",
|
||||||
|
"OAuthCredentials",
|
||||||
|
"Household",
|
||||||
|
"HouseholdMember",
|
||||||
|
"HouseholdInvitation",
|
||||||
|
]:
|
||||||
async with conn.execute(
|
async with conn.execute(
|
||||||
"SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (tbl,)
|
"SELECT name FROM sqlite_master WHERE type='table' AND name=?;", (tbl,)
|
||||||
) as c:
|
) as c:
|
||||||
|
|
@ -51,7 +58,9 @@ def test_migration_adds_tables_and_columns_and_ports_data(tmp_path):
|
||||||
assert await table_has_column(conn, tbl, "household_id"), f"{tbl} lacks household_id"
|
assert await table_has_column(conn, tbl, "household_id"), f"{tbl} lacks household_id"
|
||||||
|
|
||||||
# Default household exists
|
# Default household exists
|
||||||
async with conn.execute("SELECT id, slug FROM Household WHERE slug='default' LIMIT 1;") as c:
|
async with conn.execute(
|
||||||
|
"SELECT id, slug FROM Household WHERE slug='default' LIMIT 1;"
|
||||||
|
) as c:
|
||||||
row = await c.fetchone()
|
row = await c.fetchone()
|
||||||
assert row is not None
|
assert row is not None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -80,7 +80,5 @@ class TestRecipesHouseholdV2(unittest.IsolatedAsyncioTestCase):
|
||||||
assert not any(it["id"] == rid for it in items2)
|
assert not any(it["id"] == rid for it in items2)
|
||||||
|
|
||||||
# Get in H2 by id should 404
|
# Get in H2 by id should 404
|
||||||
r = self.client.get(
|
r = self.client.get(f"/api/v1/households/{self.h2}/recipes/{rid}", headers=self.headers)
|
||||||
f"/api/v1/households/{self.h2}/recipes/{rid}", headers=self.headers
|
|
||||||
)
|
|
||||||
assert r.status_code == 404
|
assert r.status_code == 404
|
||||||
|
|
|
||||||
|
|
@ -75,17 +75,13 @@ class TestShoppingHouseholdV2(unittest.IsolatedAsyncioTestCase):
|
||||||
main.app.dependency_overrides.clear()
|
main.app.dependency_overrides.clear()
|
||||||
|
|
||||||
def test_current_is_scoped(self):
|
def test_current_is_scoped(self):
|
||||||
r1 = self.client.get(
|
r1 = self.client.get(f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers)
|
||||||
f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers
|
|
||||||
)
|
|
||||||
assert r1.status_code == 200, r1.text
|
assert r1.status_code == 200, r1.text
|
||||||
cur1 = r1.json()
|
cur1 = r1.json()
|
||||||
assert len(cur1["outstandingItems"]) == 1
|
assert len(cur1["outstandingItems"]) == 1
|
||||||
assert cur1["outstandingItems"][0]["ingredientId"] == 1
|
assert cur1["outstandingItems"][0]["ingredientId"] == 1
|
||||||
|
|
||||||
r2 = self.client.get(
|
r2 = self.client.get(f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers)
|
||||||
f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers
|
|
||||||
)
|
|
||||||
assert r2.status_code == 200, r2.text
|
assert r2.status_code == 200, r2.text
|
||||||
cur2 = r2.json()
|
cur2 = r2.json()
|
||||||
assert len(cur2["outstandingItems"]) == 1
|
assert len(cur2["outstandingItems"]) == 1
|
||||||
|
|
|
||||||
|
|
@ -103,23 +103,21 @@ class TestShoppingPurchaseV2(unittest.IsolatedAsyncioTestCase):
|
||||||
assert len(data["list"]["items"]) == 1
|
assert len(data["list"]["items"]) == 1
|
||||||
|
|
||||||
# Verify via API: H1 has no outstanding items; H2 still has one
|
# Verify via API: H1 has no outstanding items; H2 still has one
|
||||||
r1 = self.client.get(
|
r1 = self.client.get(f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers)
|
||||||
f"/api/v1/households/{self.h1}/shopping/current", headers=self.headers
|
|
||||||
)
|
|
||||||
assert r1.status_code == 200, r1.text
|
assert r1.status_code == 200, r1.text
|
||||||
cur1 = r1.json()
|
cur1 = r1.json()
|
||||||
assert len(cur1["outstandingItems"]) == 0
|
assert len(cur1["outstandingItems"]) == 0
|
||||||
|
|
||||||
r2 = self.client.get(
|
r2 = self.client.get(f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers)
|
||||||
f"/api/v1/households/{self.h2}/shopping/current", headers=self.headers
|
|
||||||
)
|
|
||||||
assert r2.status_code == 200, r2.text
|
assert r2.status_code == 200, r2.text
|
||||||
cur2 = r2.json()
|
cur2 = r2.json()
|
||||||
assert len(cur2["outstandingItems"]) == 1
|
assert len(cur2["outstandingItems"]) == 1
|
||||||
|
|
||||||
def test_purchase_validation_error(self):
|
def test_purchase_validation_error(self):
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
f"/api/v1/households/{self.h1}/shopping", headers=self.headers, json={"storeName": "woolworths", "items": []}
|
f"/api/v1/households/{self.h1}/shopping",
|
||||||
|
headers=self.headers,
|
||||||
|
json={"storeName": "woolworths", "items": []},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 400, resp.text
|
assert resp.status_code == 400, resp.text
|
||||||
assert "application/problem+json" in resp.headers.get("content-type", "")
|
assert "application/problem+json" in resp.headers.get("content-type", "")
|
||||||
|
|
|
||||||
|
|
@ -36,9 +36,7 @@ async def create(conn):
|
||||||
)
|
)
|
||||||
|
|
||||||
# Helpful indices
|
# Helpful indices
|
||||||
await conn.execute(
|
await conn.execute("CREATE INDEX IF NOT EXISTS idx_user_email ON User(email);")
|
||||||
"CREATE INDEX IF NOT EXISTS idx_user_email ON User(email);"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def get_by_email(conn, email: str):
|
async def get_by_email(conn, email: str):
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue