412 lines
12 KiB
Python
412 lines
12 KiB
Python
"""
|
|
Step 2b — Schema Hardening Tests
|
|
- test_suffix_table_exists: The suffix table is created in the schema
|
|
- test_exercise_title_unique: Duplicate exercise titles are rejected
|
|
- test_combo_title_unique: Duplicate combo titles are rejected
|
|
- test_session_title_unique: Duplicate session titles are rejected
|
|
- test_collection_label_unique: Duplicate collection labels are rejected
|
|
- test_suffix_crud: Full CRUD cycle on the suffix table
|
|
- test_suffix_unique_constraint: Duplicate suffixes are rejected
|
|
- test_suffix_user_uuid_primary_key: Duplicate user_uuid is rejected
|
|
- test_unique_constraint_allows_update: Updating a record to its own title succeeds
|
|
- test_unique_constraint_across_updates: Updating a title to an existing title fails
|
|
"""
|
|
from playwright.sync_api import Page, expect
|
|
|
|
|
|
def _wait_for_db(page: Page, app_url: str):
|
|
"""Navigate and wait for the database to be ready."""
|
|
page.goto(app_url)
|
|
page.wait_for_function(
|
|
"document.documentElement.getAttribute('data-db-ready') === 'true'", timeout=15000
|
|
)
|
|
|
|
|
|
def test_suffix_table_exists(page: Page, app_url: str):
|
|
"""The suffix table is created in the schema."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
tables = page.evaluate(
|
|
"""
|
|
window.__db.selectAll(
|
|
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
|
)
|
|
"""
|
|
)
|
|
table_names = [t["name"] for t in tables]
|
|
assert "suffix" in table_names, f"Table 'suffix' not found. Found: {table_names}"
|
|
|
|
# Verify table structure
|
|
columns = page.evaluate(
|
|
"""
|
|
window.__db.selectAll("PRAGMA table_info(suffix)")
|
|
"""
|
|
)
|
|
col_names = [c["name"] for c in columns]
|
|
assert "user_uuid" in col_names, "Column 'user_uuid' not found in suffix table"
|
|
assert "user_name" in col_names, "Column 'user_name' not found in suffix table"
|
|
assert "suffix" in col_names, "Column 'suffix' not found in suffix table"
|
|
|
|
|
|
def test_exercise_title_unique(page: Page, app_url: str):
|
|
"""Duplicate exercise titles are rejected by the UNIQUE constraint."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Create first exercise
|
|
ex_id = page.evaluate(
|
|
"""
|
|
window.__repos.exercises.create({
|
|
title: 'Unique Push-ups',
|
|
description: '',
|
|
created_by: 'test'
|
|
})
|
|
"""
|
|
)
|
|
assert ex_id is not None
|
|
|
|
# Try to create a duplicate
|
|
error = page.evaluate(
|
|
"""
|
|
(async () => {
|
|
try {
|
|
await window.__repos.exercises.create({
|
|
title: 'Unique Push-ups',
|
|
description: 'Duplicate',
|
|
created_by: 'test'
|
|
});
|
|
return null;
|
|
} catch (e) {
|
|
return e.message;
|
|
}
|
|
})()
|
|
"""
|
|
)
|
|
assert error is not None, "Duplicate exercise title was allowed"
|
|
assert "UNIQUE" in error or "unique" in error.lower(), f"Unexpected error: {error}"
|
|
|
|
# Cleanup
|
|
page.evaluate(f"window.__repos.exercises.delete('{ex_id}')")
|
|
|
|
|
|
def test_combo_title_unique(page: Page, app_url: str):
|
|
"""Duplicate combo titles are rejected by the UNIQUE constraint."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
combo_id = page.evaluate(
|
|
"""
|
|
window.__repos.combos.create({
|
|
title: 'Unique Combo',
|
|
description: '',
|
|
type: 'NONE',
|
|
created_by: 'test'
|
|
})
|
|
"""
|
|
)
|
|
assert combo_id is not None
|
|
|
|
error = page.evaluate(
|
|
"""
|
|
(async () => {
|
|
try {
|
|
await window.__repos.combos.create({
|
|
title: 'Unique Combo',
|
|
description: 'Duplicate',
|
|
type: 'NONE',
|
|
created_by: 'test'
|
|
});
|
|
return null;
|
|
} catch (e) {
|
|
return e.message;
|
|
}
|
|
})()
|
|
"""
|
|
)
|
|
assert error is not None, "Duplicate combo title was allowed"
|
|
assert "UNIQUE" in error or "unique" in error.lower(), f"Unexpected error: {error}"
|
|
|
|
# Cleanup
|
|
page.evaluate(f"window.__repos.combos.delete('{combo_id}')")
|
|
|
|
|
|
def test_session_title_unique(page: Page, app_url: str):
|
|
"""Duplicate session titles are rejected by the UNIQUE constraint."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
session_id = page.evaluate(
|
|
"""
|
|
window.__repos.sessions.create({
|
|
title: 'Unique Session',
|
|
description: '',
|
|
created_by: 'test'
|
|
})
|
|
"""
|
|
)
|
|
assert session_id is not None
|
|
|
|
error = page.evaluate(
|
|
"""
|
|
(async () => {
|
|
try {
|
|
await window.__repos.sessions.create({
|
|
title: 'Unique Session',
|
|
description: 'Duplicate',
|
|
created_by: 'test'
|
|
});
|
|
return null;
|
|
} catch (e) {
|
|
return e.message;
|
|
}
|
|
})()
|
|
"""
|
|
)
|
|
assert error is not None, "Duplicate session title was allowed"
|
|
assert "UNIQUE" in error or "unique" in error.lower(), f"Unexpected error: {error}"
|
|
|
|
# Cleanup
|
|
page.evaluate(f"window.__repos.sessions.delete('{session_id}')")
|
|
|
|
|
|
def test_collection_label_unique(page: Page, app_url: str):
|
|
"""Duplicate collection labels are rejected by the UNIQUE constraint."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
coll_id = page.evaluate(
|
|
"""
|
|
window.__repos.collections.create({
|
|
label: 'Unique Collection',
|
|
created_by: 'test'
|
|
})
|
|
"""
|
|
)
|
|
assert coll_id is not None
|
|
|
|
error = page.evaluate(
|
|
"""
|
|
(async () => {
|
|
try {
|
|
await window.__repos.collections.create({
|
|
label: 'Unique Collection',
|
|
created_by: 'test'
|
|
});
|
|
return null;
|
|
} catch (e) {
|
|
return e.message;
|
|
}
|
|
})()
|
|
"""
|
|
)
|
|
assert error is not None, "Duplicate collection label was allowed"
|
|
assert "UNIQUE" in error or "unique" in error.lower(), f"Unexpected error: {error}"
|
|
|
|
# Cleanup
|
|
page.evaluate(f"window.__repos.collections.delete('{coll_id}')")
|
|
|
|
|
|
def test_suffix_crud(page: Page, app_url: str):
|
|
"""Full CRUD cycle on the suffix table."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Insert
|
|
page.evaluate(
|
|
"""
|
|
window.__db.run(
|
|
"INSERT INTO suffix (user_uuid, user_name, suffix) VALUES (?, ?, ?)",
|
|
['uuid-001', 'Alice', 'AL']
|
|
)
|
|
"""
|
|
)
|
|
|
|
# Read
|
|
row = page.evaluate(
|
|
"""
|
|
window.__db.selectAll("SELECT * FROM suffix WHERE user_uuid = 'uuid-001'")
|
|
"""
|
|
)
|
|
assert len(row) == 1, f"Expected 1 row, got {len(row)}"
|
|
assert row[0]["user_uuid"] == "uuid-001"
|
|
assert row[0]["user_name"] == "Alice"
|
|
assert row[0]["suffix"] == "AL"
|
|
|
|
# Update
|
|
page.evaluate(
|
|
"""
|
|
window.__db.run(
|
|
"UPDATE suffix SET suffix = ?, user_name = ? WHERE user_uuid = ?",
|
|
['ALC', 'Alice C.', 'uuid-001']
|
|
)
|
|
"""
|
|
)
|
|
updated = page.evaluate(
|
|
"""
|
|
window.__db.selectAll("SELECT * FROM suffix WHERE user_uuid = 'uuid-001'")
|
|
"""
|
|
)
|
|
assert updated[0]["suffix"] == "ALC"
|
|
assert updated[0]["user_name"] == "Alice C."
|
|
|
|
# Delete
|
|
page.evaluate(
|
|
"""
|
|
window.__db.run("DELETE FROM suffix WHERE user_uuid = 'uuid-001'")
|
|
"""
|
|
)
|
|
deleted = page.evaluate(
|
|
"""
|
|
window.__db.selectAll("SELECT * FROM suffix WHERE user_uuid = 'uuid-001'")
|
|
"""
|
|
)
|
|
assert len(deleted) == 0, "Suffix row still exists after deletion"
|
|
|
|
|
|
def test_suffix_unique_constraint(page: Page, app_url: str):
|
|
"""Duplicate suffix values are rejected by the UNIQUE constraint."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
# Insert first
|
|
page.evaluate(
|
|
"""
|
|
window.__db.run(
|
|
"INSERT INTO suffix (user_uuid, user_name, suffix) VALUES (?, ?, ?)",
|
|
['uuid-100', 'Bob', 'BB']
|
|
)
|
|
"""
|
|
)
|
|
|
|
# Try duplicate suffix with different user
|
|
error = page.evaluate(
|
|
"""
|
|
(async () => {
|
|
try {
|
|
await window.__db.run(
|
|
"INSERT INTO suffix (user_uuid, user_name, suffix) VALUES (?, ?, ?)",
|
|
['uuid-200', 'Charlie', 'BB']
|
|
);
|
|
return null;
|
|
} catch (e) {
|
|
return e.message;
|
|
}
|
|
})()
|
|
"""
|
|
)
|
|
assert error is not None, "Duplicate suffix was allowed"
|
|
assert "UNIQUE" in error or "unique" in error.lower(), f"Unexpected error: {error}"
|
|
|
|
# Cleanup
|
|
page.evaluate("""window.__db.run("DELETE FROM suffix WHERE user_uuid = 'uuid-100'")""")
|
|
|
|
|
|
def test_suffix_user_uuid_primary_key(page: Page, app_url: str):
|
|
"""Duplicate user_uuid (primary key) is rejected."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
page.evaluate(
|
|
"""
|
|
window.__db.run(
|
|
"INSERT INTO suffix (user_uuid, user_name, suffix) VALUES (?, ?, ?)",
|
|
['uuid-pk-test', 'Dave', 'DV']
|
|
)
|
|
"""
|
|
)
|
|
|
|
error = page.evaluate(
|
|
"""
|
|
(async () => {
|
|
try {
|
|
await window.__db.run(
|
|
"INSERT INTO suffix (user_uuid, user_name, suffix) VALUES (?, ?, ?)",
|
|
['uuid-pk-test', 'Dave Again', 'DV2']
|
|
);
|
|
return null;
|
|
} catch (e) {
|
|
return e.message;
|
|
}
|
|
})()
|
|
"""
|
|
)
|
|
assert error is not None, "Duplicate user_uuid was allowed"
|
|
|
|
# Cleanup
|
|
page.evaluate("""window.__db.run("DELETE FROM suffix WHERE user_uuid = 'uuid-pk-test'")""")
|
|
|
|
|
|
def test_unique_constraint_allows_update(page: Page, app_url: str):
|
|
"""Updating a record to its own title succeeds (UNIQUE constraint does not block self-update)."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
ex_id = page.evaluate(
|
|
"""
|
|
window.__repos.exercises.create({
|
|
title: 'Self Update Test',
|
|
description: '',
|
|
created_by: 'test'
|
|
})
|
|
"""
|
|
)
|
|
|
|
# Update to same title should work
|
|
error = page.evaluate(
|
|
f"""
|
|
(async () => {{
|
|
try {{
|
|
await window.__repos.exercises.update('{ex_id}', {{
|
|
title: 'Self Update Test',
|
|
description: 'Updated description'
|
|
}});
|
|
return null;
|
|
}} catch (e) {{
|
|
return e.message;
|
|
}}
|
|
}})()
|
|
"""
|
|
)
|
|
assert error is None, f"Self-update was rejected: {error}"
|
|
|
|
# Cleanup
|
|
page.evaluate(f"window.__repos.exercises.delete('{ex_id}')")
|
|
|
|
|
|
def test_unique_constraint_across_updates(page: Page, app_url: str):
|
|
"""Updating a title to match another existing title fails."""
|
|
_wait_for_db(page, app_url)
|
|
|
|
id_a = page.evaluate(
|
|
"""
|
|
window.__repos.exercises.create({
|
|
title: 'Exercise A',
|
|
description: '',
|
|
created_by: 'test'
|
|
})
|
|
"""
|
|
)
|
|
id_b = page.evaluate(
|
|
"""
|
|
window.__repos.exercises.create({
|
|
title: 'Exercise B',
|
|
description: '',
|
|
created_by: 'test'
|
|
})
|
|
"""
|
|
)
|
|
|
|
# Try to update B's title to A's title
|
|
error = page.evaluate(
|
|
f"""
|
|
(async () => {{
|
|
try {{
|
|
await window.__repos.exercises.update('{id_b}', {{
|
|
title: 'Exercise A'
|
|
}});
|
|
return null;
|
|
}} catch (e) {{
|
|
return e.message;
|
|
}}
|
|
}})()
|
|
"""
|
|
)
|
|
assert error is not None, "Updating to a duplicate title was allowed"
|
|
assert "UNIQUE" in error or "unique" in error.lower(), f"Unexpected error: {error}"
|
|
|
|
# Cleanup
|
|
page.evaluate(f"window.__repos.exercises.delete('{id_a}')")
|
|
page.evaluate(f"window.__repos.exercises.delete('{id_b}')")
|