diff --git a/README.md b/README.md index ffe80bb..e51429c 100644 --- a/README.md +++ b/README.md @@ -142,3 +142,74 @@ code to github and take the rest of the afternoon off to ~~recover~~ relax! 1. Upload all of your code to your forked github repo in a new branch, and create a pull request with your changes into the main branch. 2. Share your branch name with your recruiting contact, who will be in touch regarding the results of your test. + +--- + +## Setup and Execution + +### Prerequisites + +- Docker Desktop installed and running +- Python 3.9+ with pip + +### Start the Trino container + +The container must be started with host port mapping so the Python test client can connect: + +```bash +docker run --name=sexi-silverbullet -p 8080:8080 -d trinodb/trino +``` + +Wait ~30 seconds for startup, then verify: + +```bash +docker exec -it sexi-silverbullet trino --execute "SELECT 'ready'" +``` + +To reset the in-memory database at any time, restart the container: + +```bash +docker restart sexi-silverbullet +``` + +### Execute the SQL files + +Run in dependency order (employees before expenses, supplier before invoice): + +```bash +cat create_employees.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default +cat create_expenses.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default +cat create_invoices.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default +cat find_manager_cycles.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default +cat calculate_largest_expensors.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default +cat generate_supplier_payment_plans.sql | docker exec -i sexi-silverbullet trino --catalog memory --schema default +``` + +### Run the automated tests + +```bash +pip install -r tests/requirements.txt +pytest tests/ -v +``` + +The suite covers two SQL solutions across 21 tests: + +| Test file | SQL under test | +|---|---| +| `test_calculate_largest_expensors.py` | `calculate_largest_expensors.sql` | +| `test_find_manager_cycles.py` | `find_manager_cycles.sql` | + +Each test controls the relevant tables directly so tests are fully isolated. Shared helpers (`run_sql_file`, `load_employees`, `column_names`) live in `conftest.py`. If the container is not reachable, pytest fails immediately with a connection error. + +### Catalog and schema + +All tables use `memory.default`. The in-memory catalog is reset whenever the container restarts; re-run the SQL files after any restart. + +### Key assumptions + +- `"N months from now"` means the last calendar day of the month that is N months after the current date (`last_day_of_month(date_add('month', N, current_date))`). This matches the Catering Plus sample in the README exactly. +- `invoice_ammount` (double-m) is the authoritative column name as specified in the README. +- Payment instalments are truncated (not rounded) to the nearest cent; the final instalment absorbs any remainder so each invoice sums to zero exactly. +- Supplier IDs are assigned by case-insensitive alphabetical order of company name. +- The manager-cycle query requires Trino 415 or later (supports `WITH RECURSIVE`). The `trinodb/trino:latest` image satisfies this requirement. +- The Trino Python client does not accept a trailing semicolon; `run_query()` strips it before executing the SQL file. diff --git a/calculate_largest_expensors.sql b/calculate_largest_expensors.sql index e69de29..e587d26 100644 --- a/calculate_largest_expensors.sql +++ b/calculate_largest_expensors.sql @@ -0,0 +1,20 @@ +SELECT + e.employee_id, + e.first_name || ' ' || e.last_name AS employee_name, + e.manager_id, + m.first_name || ' ' || m.last_name AS manager_name, + CAST(SUM(ex.unit_price * ex.quantity) AS DECIMAL(12, 2)) AS total_expensed_amount +FROM memory.default.expense AS ex +JOIN memory.default.employee AS e ON e.employee_id = ex.employee_id +LEFT JOIN memory.default.employee AS m ON m.employee_id = e.manager_id +GROUP BY + e.employee_id, + e.first_name, + e.last_name, + e.manager_id, + m.first_name, + m.last_name +HAVING SUM(ex.unit_price * ex.quantity) > DECIMAL '1000.00' +ORDER BY + total_expensed_amount DESC, + e.employee_id ASC; diff --git a/create_employees.sql b/create_employees.sql index e69de29..e386b74 100644 --- a/create_employees.sql +++ b/create_employees.sql @@ -0,0 +1,24 @@ +USE memory.default; + +DROP TABLE IF EXISTS memory.default.employee; + +CREATE TABLE memory.default.employee ( + employee_id TINYINT, + first_name VARCHAR, + last_name VARCHAR, + job_title VARCHAR, + manager_id TINYINT +); + +INSERT INTO memory.default.employee + (employee_id, first_name, last_name, job_title, manager_id) +VALUES + (1, 'Ian', 'James', 'CEO', 4), + (2, 'Umberto', 'Torrielli', 'CSO', 1), + (3, 'Alex', 'Jacobson', 'MD EMEA', 2), + (4, 'Darren', 'Poynton', 'CFO', 2), + (5, 'Tim', 'Beard', 'MD APAC', 2), + (6, 'Gemma', 'Dodd', 'COS', 1), + (7, 'Lisa', 'Platten', 'CHR', 6), + (8, 'Stefano', 'Camisaca', 'GM Activation', 2), + (9, 'Andrea', 'Ghibaudi', 'MD NAM', 2); diff --git a/create_expenses.sql b/create_expenses.sql index e69de29..db17b52 100644 --- a/create_expenses.sql +++ b/create_expenses.sql @@ -0,0 +1,20 @@ +USE memory.default; + +DROP TABLE IF EXISTS memory.default.expense; + +CREATE TABLE memory.default.expense ( + employee_id TINYINT, + unit_price DECIMAL(8, 2), + quantity TINYINT +); + +INSERT INTO memory.default.expense + (employee_id, unit_price, quantity) +VALUES + (3, DECIMAL '6.50', 14), -- Alex Jacobson: drinkies.txt + (3, DECIMAL '11.00', 20), -- Alex Jacobson: drinks.txt + (3, DECIMAL '22.00', 18), -- Alex Jacobson: drinkss.txt + (3, DECIMAL '13.00', 75), -- Alex Jacobson: duh_i_think_i_got_too_many.txt + (9, DECIMAL '300.00', 1), -- Andrea Ghibaudi: i_got_lost_on_the_way_home_and_now_im_in_mexico.txt + (4, DECIMAL '40.00', 9), -- Darren Poynton: ubers.txt + (2, DECIMAL '17.50', 4); -- Umberto Torrielli: we_stopped_for_a_kebabs.txt diff --git a/create_invoices.sql b/create_invoices.sql index e69de29..4baa1bc 100644 --- a/create_invoices.sql +++ b/create_invoices.sql @@ -0,0 +1,35 @@ +USE memory.default; + +DROP TABLE IF EXISTS memory.default.invoice; +DROP TABLE IF EXISTS memory.default.supplier; + +CREATE TABLE memory.default.supplier ( + supplier_id TINYINT, + name VARCHAR +); + +-- Suppliers assigned IDs in case-insensitive alphabetical order of name. +INSERT INTO memory.default.supplier (supplier_id, name) +VALUES + (1, 'Catering Plus'), + (2, 'Dave''s Discos'), + (3, 'Entertainment tonight'), + (4, 'Ice Ice Baby'), + (5, 'Party Animals'); + +CREATE TABLE memory.default.invoice ( + supplier_id TINYINT, + invoice_ammount DECIMAL(8, 2), + due_date DATE +); + +-- Due dates computed as the last day of the month N months from today. +-- "N months from now" means the month-end N calendar months after current_date. +INSERT INTO memory.default.invoice (supplier_id, invoice_ammount, due_date) +VALUES + (1, DECIMAL '2000.00', last_day_of_month(date_add('month', 2, current_date))), -- brilliant_bottles.txt + (1, DECIMAL '1500.00', last_day_of_month(date_add('month', 3, current_date))), -- crazy_catering.txt + (2, DECIMAL '500.00', last_day_of_month(date_add('month', 1, current_date))), -- disco_dj.txt + (3, DECIMAL '6000.00', last_day_of_month(date_add('month', 3, current_date))), -- excellent_entertainment.txt + (4, DECIMAL '4000.00', last_day_of_month(date_add('month', 6, current_date))), -- fantastic_ice_sculptures.txt + (5, DECIMAL '6000.00', last_day_of_month(date_add('month', 3, current_date))); -- awesome_animals.txt diff --git a/find_manager_cycles.sql b/find_manager_cycles.sql index e69de29..60096ce 100644 --- a/find_manager_cycles.sql +++ b/find_manager_cycles.sql @@ -0,0 +1,48 @@ +-- Detect manager approval cycles using recursive CTE traversal. +-- Each traversal starts from one employee and follows manager_id links, +-- recording visited nodes to prevent infinite recursion. +-- A cycle is detected when the next step would revisit a node already in the path. +-- Only the traversal starting from the minimum employee_id in the cycle is kept +-- each cycle is reported exactly once. +WITH RECURSIVE manager_chain (start_id, current_id, next_id, visited) AS ( + -- Base: begin a separate traversal from every employee that has a manager. + SELECT + employee_id AS start_id, + employee_id AS current_id, + manager_id AS next_id, + ARRAY[employee_id] AS visited + FROM memory.default.employee + WHERE manager_id IS NOT NULL + + UNION ALL + + -- Recursive: advance one step along the manager chain, stopping before + -- revisiting any node already in the visited array. + SELECT + mc.start_id, + mc.next_id AS current_id, + e.manager_id AS next_id, + mc.visited || ARRAY[mc.next_id] AS visited + FROM manager_chain AS mc + JOIN memory.default.employee AS e ON e.employee_id = mc.next_id + WHERE mc.next_id IS NOT NULL + AND NOT contains(mc.visited, mc.next_id) +), +canonical_cycle (cycle_members, cycle_string) AS ( + -- Rows where the chain has returned to its own start_id complete a full cycle. + -- array_min(visited) = start_id ensures each cycle is represented only once, + -- anchored to its smallest member. + SELECT + visited AS cycle_members, + array_join(visited, ' -> ') AS cycle_string + FROM manager_chain + WHERE next_id IS NOT NULL + AND next_id = start_id + AND start_id = array_min(visited) +) +SELECT + member AS employee_id, + cycle_string AS cycle +FROM canonical_cycle +CROSS JOIN UNNEST(cycle_members) AS t(member) +ORDER BY employee_id; diff --git a/generate_supplier_payment_plans.sql b/generate_supplier_payment_plans.sql index e69de29..243b495 100644 --- a/generate_supplier_payment_plans.sql +++ b/generate_supplier_payment_plans.sql @@ -0,0 +1,67 @@ +WITH +-- For each invoice, compute N payment periods and generate one row per period (0 through N-1). +-- N = number of months between end of current month and the invoice due date. +invoice_periods AS ( + SELECT + i.supplier_id, + i.invoice_ammount, + date_diff('month', last_day_of_month(current_date), i.due_date) AS n_periods, + period + FROM memory.default.invoice AS i + CROSS JOIN UNNEST(SEQUENCE( + CAST(0 AS BIGINT), + date_diff('month', last_day_of_month(current_date), i.due_date) - 1 + )) AS t(period) +), +-- Calculate the payment amount for each invoice in each period. +-- Base amount is truncated (floored) to the cent; the final period absorbs the remainder +-- so the invoice total is paid exactly. +invoice_payments AS ( + SELECT + supplier_id, + last_day_of_month(date_add('month', CAST(period AS INTEGER), current_date)) AS payment_date, + CASE + WHEN period = n_periods - 1 + THEN invoice_ammount + - CAST(n_periods - 1 AS DECIMAL(10, 0)) + * TRUNCATE(invoice_ammount / CAST(n_periods AS DECIMAL(10, 0)), 2) + ELSE TRUNCATE(invoice_ammount / CAST(n_periods AS DECIMAL(10, 0)), 2) + END AS period_payment + FROM invoice_periods +), +-- Aggregate payments across all invoices for each supplier into one row per month. +monthly_payments AS ( + SELECT + supplier_id, + payment_date, + SUM(period_payment) AS payment_amount + FROM invoice_payments + GROUP BY supplier_id, payment_date +), +-- Total invoice balance per supplier, used to compute balance_outstanding. +supplier_totals AS ( + SELECT + supplier_id, + SUM(invoice_ammount) AS total_balance + FROM memory.default.invoice + GROUP BY supplier_id +) +SELECT + mp.supplier_id, + s.name AS supplier_name, + CAST(mp.payment_amount AS DECIMAL(10, 2)) AS payment_amount, + -- Running balance: total owed minus cumulative payments up to and including this row. + CAST( + st.total_balance + - SUM(mp.payment_amount) OVER ( + PARTITION BY mp.supplier_id + ORDER BY mp.payment_date + ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW + ) + AS DECIMAL(10, 2) + ) AS balance_outstanding, + mp.payment_date +FROM monthly_payments AS mp +JOIN memory.default.supplier AS s ON s.supplier_id = mp.supplier_id +JOIN supplier_totals AS st ON st.supplier_id = mp.supplier_id +ORDER BY mp.supplier_id ASC, mp.payment_date ASC; diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..6c27208 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,88 @@ +"""Shared fixtures and helpers for the SExI test suite.""" + +import pathlib +import re + +import trino +import pytest + +REPO = pathlib.Path(__file__).parent.parent + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture(scope="session") +def trino_conn(): + """Open a single Trino connection for the test session. + + Requires the SExI Trino container running with port 8080 mapped to the host. + Fails with a connection error if the container is not reachable. + """ + conn = trino.dbapi.connect( + host="localhost", + port=8080, + user="pytest", + catalog="memory", + schema="default", + ) + yield conn + conn.close() + + +@pytest.fixture +def cursor(trino_conn): + """Return a fresh DB-API cursor for one test.""" + cur = trino_conn.cursor() + yield cur + cur.close() + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +def run_sql_file(cursor, path: pathlib.Path) -> None: + """Execute every statement in a SQL file.""" + for stmt in path.read_text().split(";"): + # Splitting on ";" can leave trailing fragments that are pure comments; + # strip line comments before deciding whether the segment is executable. + if re.sub(r"--[^\n]*", "", stmt).strip(): + cursor.execute(stmt.strip()) + + +def load_employees(cursor, rows: list[tuple]) -> None: + """Drop, create, and populate EMPLOYEE with the given rows. + + Args: + rows: Tuples of (employee_id, first_name, last_name, job_title, manager_id). + manager_id may be None. + """ + cursor.execute("DROP TABLE IF EXISTS memory.default.employee") + cursor.execute(""" + CREATE TABLE memory.default.employee ( + employee_id TINYINT, + first_name VARCHAR, + last_name VARCHAR, + job_title VARCHAR, + manager_id TINYINT + ) + """) + if rows: + value_literals = [] + for emp_id, first, last, title, mgr_id in rows: + mgr = "NULL" if mgr_id is None else str(int(mgr_id)) + value_literals.append( + f"({int(emp_id)}, '{first}', '{last}', '{title}', {mgr})" + ) + cursor.execute( + "INSERT INTO memory.default.employee " + "(employee_id, first_name, last_name, job_title, manager_id) VALUES " + + ", ".join(value_literals) + ) + + +def column_names(cursor) -> list[str]: + """Return column names from the last executed query.""" + return [d[0] for d in cursor.description] diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..60a666d --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,2 @@ +trino>=0.320.0 +pytest>=7.4 diff --git a/tests/test_calculate_largest_expensors.py b/tests/test_calculate_largest_expensors.py new file mode 100644 index 0000000..aefc3fb --- /dev/null +++ b/tests/test_calculate_largest_expensors.py @@ -0,0 +1,222 @@ +""" +Tests for calculate_largest_expensors.sql. + +The tests control the EMPLOYEE and EXPENSE tables, execute the SQL file +from the repository, and validate the result contract. +""" + +import pathlib +from decimal import Decimal + +from conftest import REPO, run_sql_file, load_employees, column_names + +SQL_FILE = REPO / "calculate_largest_expensors.sql" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def load_expenses(cursor, rows: list[tuple]) -> None: + """Drop, create, and populate EXPENSE with the given rows. + + Args: + rows: Tuples of (employee_id, unit_price, quantity). + unit_price may be a string like '6.50' or a Decimal. + """ + cursor.execute("DROP TABLE IF EXISTS memory.default.expense") + cursor.execute(""" + CREATE TABLE memory.default.expense ( + employee_id TINYINT, + unit_price DECIMAL(8, 2), + quantity TINYINT + ) + """) + if rows: + value_literals = [] + for emp_id, unit_price, qty in rows: + value_literals.append( + f"({int(emp_id)}, DECIMAL '{unit_price}', {int(qty)})" + ) + cursor.execute( + "INSERT INTO memory.default.expense " + "(employee_id, unit_price, quantity) VALUES " + + ", ".join(value_literals) + ) + + +def run_query(cursor) -> list[tuple]: + """Execute calculate_largest_expensors.sql and return all rows. + + The Trino Python client does not accept a trailing semicolon, so it is + stripped before execution. The SQL file retains its semicolon for correct + CLI behaviour. + """ + cursor.execute(SQL_FILE.read_text().rstrip().rstrip(";")) + return cursor.fetchall() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_column_contract(cursor): + """Result must have exactly the five required columns in correct order.""" + load_employees(cursor, [(1, "A", "B", "CEO", None), (2, "C", "D", "Staff", 1)]) + load_expenses(cursor, [(1, "1001.00", 1)]) + run_query(cursor) + assert column_names(cursor) == [ + "employee_id", + "employee_name", + "manager_id", + "manager_name", + "total_expensed_amount", + ] + + +def test_single_expense_above_threshold(cursor): + """One expense whose line total exceeds 1000 produces exactly one result row.""" + load_employees(cursor, [ + (1, "Jane", "Doe", "CEO", 2), + (2, "Bob", "Smith", "CFO", None), + ]) + load_expenses(cursor, [(1, "1001.00", 1)]) + rows = run_query(cursor) + assert len(rows) == 1 + assert rows[0][0] == 1 + assert rows[0][4] == Decimal("1001.00") + + +def test_multiple_expenses_aggregate_above_threshold(cursor): + """Multiple expenses for one employee aggregate correctly above the threshold.""" + load_employees(cursor, [ + (1, "Jane", "Doe", "Staff", 2), + (2, "Boss", "Man", "CEO", None), + ]) + load_expenses(cursor, [ + (1, "400.00", 1), + (1, "400.00", 1), + (1, "201.00", 1), # total = 1001.00 + ]) + rows = run_query(cursor) + assert len(rows) == 1 + assert rows[0][4] == Decimal("1001.00") + + +def test_exactly_1000_is_excluded(cursor): + """An employee with total exactly 1000.00 must not appear (strict > threshold).""" + load_employees(cursor, [(1, "Edge", "Case", "Staff", None)]) + load_expenses(cursor, [(1, "500.00", 2)]) # 500 * 2 = 1000.00 + rows = run_query(cursor) + assert rows == [] + + +def test_below_threshold_is_excluded(cursor): + """An employee below 1000.00 is excluded.""" + load_employees(cursor, [(1, "Low", "Spend", "Staff", None)]) + load_expenses(cursor, [(1, "100.00", 5)]) # 500.00 + rows = run_query(cursor) + assert rows == [] + + +def test_no_expenses_employee_excluded(cursor): + """An employee with no expense rows does not appear in the result.""" + load_employees(cursor, [ + (1, "No", "Expenses", "Staff", None), + (2, "High", "Spender", "Staff", None), + ]) + load_expenses(cursor, [(2, "2000.00", 1)]) + rows = run_query(cursor) + assert len(rows) == 1 + assert rows[0][0] == 2 + + +def test_descending_order_two_qualifiers(cursor): + """When two employees qualify, the higher total comes first.""" + load_employees(cursor, [ + (1, "High", "Spender", "Staff", 3), + (2, "Mid", "Spender", "Staff", 3), + (3, "Boss", "Person", "CEO", None), + ]) + load_expenses(cursor, [ + (1, "2000.00", 1), + (2, "1500.00", 1), + ]) + rows = run_query(cursor) + assert len(rows) == 2 + assert rows[0][0] == 1 # higher total first + assert rows[1][0] == 2 + + +def test_tie_broken_by_employee_id(cursor): + """Tied totals are broken by employee_id ascending.""" + load_employees(cursor, [ + (1, "First", "Tie", "Staff", None), + (2, "Second", "Tie", "Staff", None), + ]) + load_expenses(cursor, [ + (1, "1500.00", 1), + (2, "1500.00", 1), + ]) + rows = run_query(cursor) + assert len(rows) == 2 + assert rows[0][0] == 1 # lower employee_id first on tie + assert rows[1][0] == 2 + + +def test_employee_name_format(cursor): + """employee_name is first_name + space + last_name.""" + load_employees(cursor, [ + (1, "Alice", "Wonderland", "Staff", 2), + (2, "Carol", "Manager", "CEO", None), + ]) + load_expenses(cursor, [(1, "1001.00", 1)]) + rows = run_query(cursor) + assert rows[0][1] == "Alice Wonderland" + + +def test_manager_name_format(cursor): + """manager_name is first_name + space + last_name of the manager employee.""" + load_employees(cursor, [ + (1, "Alice", "Worker", "Staff", 2), + (2, "Carol", "Manager", "CEO", None), + ]) + load_expenses(cursor, [(1, "1001.00", 1)]) + rows = run_query(cursor) + assert rows[0][2] == 2 # manager_id + assert rows[0][3] == "Carol Manager" # manager_name + + +def test_empty_result_when_none_qualify(cursor): + """Result is empty when no employee exceeds the threshold.""" + load_employees(cursor, [(1, "Cheap", "Charlie", "Staff", None)]) + load_expenses(cursor, [(1, "10.00", 5)]) # 50.00 + rows = run_query(cursor) + assert rows == [] + + +def test_monetary_values_are_exact_decimals(cursor): + """Total expensed amount is an exact Decimal value, not a float approximation. + + 3 * 333.34 = 1000.02 in exact decimal arithmetic. + In IEEE 754 double, 3 * 333.34 ≈ 1000.0199999..., so this catches any + accidental use of floating-point arithmetic. + """ + load_employees(cursor, [(1, "Dec", "Test", "Staff", None)]) + load_expenses(cursor, [(1, "333.34", 3)]) # 333.34 * 3 = 1000.02 + rows = run_query(cursor) + assert len(rows) == 1 + assert rows[0][4] == Decimal("1000.02") + + +def test_actual_dataset_result(cursor): + """Smoke test on the full production data set: only Alex Jacobson qualifies.""" + run_sql_file(cursor, REPO / "create_employees.sql") + run_sql_file(cursor, REPO / "create_expenses.sql") + rows = run_query(cursor) + assert len(rows) == 1 + assert rows[0][0] == 3 # employee_id + assert rows[0][1] == "Alex Jacobson" + assert rows[0][2] == 2 # manager_id + assert rows[0][3] == "Umberto Torrielli" + assert rows[0][4] == Decimal("1682.00") diff --git a/tests/test_find_manager_cycles.py b/tests/test_find_manager_cycles.py new file mode 100644 index 0000000..dd42391 --- /dev/null +++ b/tests/test_find_manager_cycles.py @@ -0,0 +1,113 @@ +""" +Tests for find_manager_cycles.sql. + +The tests control the EMPLOYEE table, execute the SQL file from the repository, +and validate the result contract. +""" + +from conftest import REPO, run_sql_file, load_employees, column_names + +SQL_FILE = REPO / "find_manager_cycles.sql" + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def run_query(cursor) -> list[tuple]: + """Execute find_manager_cycles.sql and return all rows.""" + cursor.execute(SQL_FILE.read_text().rstrip().rstrip(";")) + return cursor.fetchall() + + +# --------------------------------------------------------------------------- +# Tests +# --------------------------------------------------------------------------- + +def test_column_contract(cursor): + """Result must have exactly the two required columns in correct order.""" + load_employees(cursor, [ + (1, "A", "A", "Staff", 2), + (2, "B", "B", "Staff", 1), + ]) + run_query(cursor) + assert column_names(cursor) == ["employee_id", "cycle"] + + +def test_no_cycles_returns_empty(cursor): + """A linear manager chain with no cycle produces an empty result.""" + load_employees(cursor, [ + (1, "Top", "Dog", "CEO", None), + (2, "Mid", "Guy", "VP", 1), + (3, "Low", "Guy", "Staff", 2), + ]) + rows = run_query(cursor) + assert rows == [] + + +def test_two_person_cycle(cursor): + """A <-> B: both members appear, sharing the same cycle string.""" + load_employees(cursor, [ + (1, "Alice", "A", "Staff", 2), + (2, "Bob", "B", "Staff", 1), + ]) + rows = run_query(cursor) + assert len(rows) == 2 + assert {r[0] for r in rows} == {1, 2} + assert rows[0][1] == rows[1][1] + + +def test_cycle_string_format(cursor): + """Cycle string lists IDs separated by ' -> ', anchored at the minimum member.""" + load_employees(cursor, [ + (1, "A", "A", "Staff", 2), + (2, "B", "B", "Staff", 1), + ]) + rows = run_query(cursor) + assert {r[1] for r in rows} == {"1 -> 2"} + + +def test_three_person_cycle(cursor): + """A -> B -> C -> A: all three members reported with the correct cycle string.""" + load_employees(cursor, [ + (1, "A", "A", "Staff", 2), + (2, "B", "B", "Staff", 3), + (3, "C", "C", "Staff", 1), + ]) + rows = run_query(cursor) + assert len(rows) == 3 + assert {r[0] for r in rows} == {1, 2, 3} + assert {r[1] for r in rows} == {"1 -> 2 -> 3"} + + +def test_non_cycle_employees_excluded(cursor): + """Employees who point into a cycle but are not part of it are excluded.""" + load_employees(cursor, [ + (1, "Cycle", "A", "Staff", 2), + (2, "Cycle", "B", "Staff", 1), + (3, "Linear", "C", "Staff", 1), # points into cycle, not in it + (4, "Root", "D", "CEO", None), + ]) + rows = run_query(cursor) + assert {r[0] for r in rows} == {1, 2} + + +def test_results_ordered_by_employee_id(cursor): + """Rows are returned in ascending employee_id order.""" + load_employees(cursor, [ + (3, "C", "C", "Staff", 1), + (1, "A", "A", "Staff", 2), + (2, "B", "B", "Staff", 3), + ]) + rows = run_query(cursor) + ids = [r[0] for r in rows] + assert ids == sorted(ids) + + +def test_actual_dataset_result(cursor): + """Production employees contain exactly one cycle: Ian(1) – Darren(4) – Umberto(2).""" + run_sql_file(cursor, REPO / "create_employees.sql") + rows = run_query(cursor) + assert len(rows) == 3 + assert {r[0] for r in rows} == {1, 2, 4} + assert {r[1] for r in rows} == {"1 -> 4 -> 2"}