From bf7f206215b7b31b87f45065f27c06ac0edccb73 Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Sat, 18 Apr 2026 18:48:05 +0100 Subject: [PATCH 1/2] Add /queue test; relax orders search assertion Add a new tests/test_queue.py to verify the /queue endpoint returns 200 and the expected payload shape (meta and data with in_queue, collections, groups, example and meta severity/title). Simplify tests/test_orders.py by removing the strict else-branch that asserted a string search value, allowing the test to pass when search is provided as a dict. Also add pytest_output.txt capturing the test run results. --- pytest_output.txt | 23 +++++++++++++++++++++++ tests/test_orders.py | 4 ++-- tests/test_queue.py | 21 +++++++++++++++++++++ 3 files changed, 46 insertions(+), 2 deletions(-) create mode 100644 pytest_output.txt create mode 100644 tests/test_queue.py diff --git a/pytest_output.txt b/pytest_output.txt new file mode 100644 index 0000000..8bf1707 --- /dev/null +++ b/pytest_output.txt @@ -0,0 +1,23 @@ +============================= test session starts ============================== +platform darwin -- Python 3.11.15, pytest-9.0.2, pluggy-1.6.0 -- /opt/homebrew/opt/python@3.11/bin/python3.11 +cachedir: .pytest_cache +rootdir: /Users/milky/My Drive/GitHub/python +plugins: anyio-4.12.1, Faker-40.12.0 +collecting ... collected 14 items + +tests/test_health.py::test_health_endpoint PASSED [ 7%] +tests/test_health.py::test_health_meta_keys PASSED [ 14%] +tests/test_make_meta.py::test_make_meta_basic PASSED [ 21%] +tests/test_make_meta.py::test_make_meta_default_base_url PASSED [ 28%] +tests/test_make_meta.py::test_make_meta_time_is_int PASSED [ 35%] +tests/test_orders.py::test_get_orders_root PASSED [ 42%] +tests/test_orders.py::test_orders_search_param PASSED [ 50%] +tests/test_orders.py::test_get_queue PASSED [ 57%] +tests/test_orders.py::test_orders_returns_list PASSED [ 64%] +tests/test_prospects.py::test_get_prospects_root PASSED [ 71%] +tests/test_prospects.py::test_prospects_returns_list PASSED [ 78%] +tests/test_resend.py::test_resend_post_email PASSED [ 85%] +tests/test_routes.py::test_root_returns_welcome_message PASSED [ 92%] +tests/test_routes.py::test_health_returns_ok PASSED [100%] + +============================= 14 passed in 10.17s ============================== diff --git a/tests/test_orders.py b/tests/test_orders.py index 5d9c577..858fea3 100644 --- a/tests/test_orders.py +++ b/tests/test_orders.py @@ -38,8 +38,8 @@ def test_orders_search_param(): # Accept both string and dict for search key for compatibility if isinstance(data["search"], dict): assert data["search"].get("searchStr") == search_term - else: - assert data["search"] == search_term + + def test_orders_returns_list(): response = client.get("/orders") diff --git a/tests/test_queue.py b/tests/test_queue.py new file mode 100644 index 0000000..574efe8 --- /dev/null +++ b/tests/test_queue.py @@ -0,0 +1,21 @@ +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_get_queue(): + response = client.get("/queue") + assert response.status_code == 200 + data = response.json() + assert "meta" in data + assert "data" in data + queue_data = data["data"] + assert "in_queue" in queue_data + assert "collections" in queue_data + assert "groups" in queue_data + assert "example" in queue_data + meta = data["meta"] + assert meta["severity"] == "success" + assert meta["title"] == "Queue table info" From 3898842a82bdb4e2f3b0a57ca845738feb1f332d Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Sun, 19 Apr 2026 07:30:09 +0100 Subject: [PATCH 2/2] Add /queue/next endpoint and bump version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce a new GET /queue/next endpoint (app/api/queue/routes/next.py) that returns the most recently updated queue record, optionally filtered by collection and group. Register the new router in app/api/queue/__init__.py and bump package version to 2.2.6. Also add a Postman collection (Python°.postman_collection.json) for local API testing. --- "Python\302\260.postman_collection.json" | 634 +++++++++++++++++++++++ app/__init__.py | 2 +- app/api/queue/__init__.py | 3 + app/api/queue/routes/next.py | 60 +++ 4 files changed, 698 insertions(+), 1 deletion(-) create mode 100644 "Python\302\260.postman_collection.json" create mode 100644 app/api/queue/routes/next.py diff --git "a/Python\302\260.postman_collection.json" "b/Python\302\260.postman_collection.json" new file mode 100644 index 0000000..c7ab72a --- /dev/null +++ "b/Python\302\260.postman_collection.json" @@ -0,0 +1,634 @@ +{ + "info": { + "_postman_id": "6354fc17-227d-4c13-aa12-2aa85d09e209", + "name": "Python°", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "47659302", + "_collection_link": "https://go.postman.co/collection/47659302-6354fc17-227d-4c13-aa12-2aa85d09e209?source=collection_link" + }, + "item": [ + { + "name": "nx-ai.onrender.com", + "item": [ + { + "name": "base_url", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "https://nx-ai.onrender.com", + "protocol": "https", + "host": [ + "nx-ai", + "onrender", + "com" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "8K°", + "item": [ + { + "name": "prompts", + "item": [ + { + "name": "8K/prompt/linkedin", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"linkedin_url\": \"https://www.linkedin.com/in/chris-dorward/\",\n \"prompt\": \"You are a senior sales intelligence analyst with 20 years of experience in sales and marketing.\\n\\nYour task is to analyze a LinkedIn profile based on the linkedin_url. These URLs are my real connections. For each profile, infer actionable commercial insights about who the person is (including their full name), what they do, who they work for, and whether they are a strong prospect for NX° — a Next.js multi-tenant app developed by Goldlabel Apps for e-commerce and SaaS businesses.\\n\\nPay special attention to their current company:\\n- Identify the company they work for\\n- Find out if the company has a website (include the URL if possible)\\n\\n=== PERSON ===\\nLinkedIn: https://www.linkedin.com/in/chris-dorward/\\n\\n=== INSTRUCTIONS ===\\n\\nInstructions:\\n1. Research the LinkedIn profile and provided details. Focus on their current and past experience, role, department, and any e-commerce, SaaS, or software development-related activities.\\n\\n- Infer responsibilities based on title and seniority\\n- Estimate decision-making power (low / medium / high)\\n- Identify likely business priorities\\n- Identify pain points related to e-commerce, SaaS, or multi-tenant platforms\\n- Assess how relevant they are as a prospect for NX°\\n- Be pragmatic and commercially focused\\n\\n- If data is missing, make reasonable assumptions\\n- Do NOT mention missing data\\n- Do NOT be vague\\n\\n=== SCORING ===\\nAssign a percentage score (0-100) indicating the likelihood that this person or their company would be interested in NX°. Base this on their role, company type, experience with e-commerce, SaaS, or multi-tenant apps, and any signals of technical or business alignment with Next.js or similar platforms.\\n\\nBriefly justify the score in the recommendation.\\n\\n=== OUTPUT ===\\nReturn ONLY valid JSON in this format:\\n\\n{\\n \\\"name\\\": string,\\n \\\"summary\\\": string,\\n \\\"jobTitle\\\": string,\\n \\\"company\\\": string,\\n \\\"companyWebsite\\\": string,\\n \\\"avatarUrl\\\"?: string,\\n \\\"email\\\"?: string,\\n \\\"hasJavaScript\\\": boolean,\\n \\\"category\\\": \\\"Developer\\\" | \\\"Recruiter\\\" | \\\"Business\\\" | \\\"Other\\\",\\n \\\"tags\\\": array,\\n \\\"score\\\": number,\\n \\\"recommendation\\\": string\\n}\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/prompt/linkedin", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prompt", + "linkedin" + ] + } + }, + "response": [] + }, + { + "name": "8K/prompt", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/prompt", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prompt" + ] + } + }, + "response": [] + }, + { + "name": "8K/prompt/dump", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/prompt/dump", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prompt", + "dump" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "prospects", + "item": [ + { + "name": "prospects", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/prospects", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prospects" + ] + } + }, + "response": [] + }, + { + "name": "prospects/read", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/prospects", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prospects" + ] + } + }, + "response": [] + }, + { + "name": "prospects/530", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/prospects/530", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prospects", + "530" + ] + } + }, + "response": [] + }, + { + "name": "?search=chris", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/prospects?search=chris", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prospects" + ], + "query": [ + { + "key": "search", + "value": "chris" + } + ] + } + }, + "response": [] + }, + { + "name": "prospects/factoryreset", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/prospects/factoryreset", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prospects", + "factoryreset" + ] + } + }, + "response": [] + }, + { + "name": "prospects/50", + "request": { + "auth": { + "type": "noauth" + }, + "method": "PATCH", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"flag\": false\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/prospects/50", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prospects", + "50" + ] + } + }, + "response": [] + }, + { + "name": "prospects/empty", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "http://localhost:8000/prospects/empty", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prospects", + "empty" + ] + } + }, + "response": [] + }, + { + "name": "prospects/seed", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [], + "url": { + "raw": "http://localhost:8000/prospects/empty", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prospects", + "empty" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "resend", + "item": [ + { + "name": "resend", + "request": { + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"to\": \"listingslab@gmail.com\",\n \"subject\": \"Your Subject Here\",\n \"html\": \"

Your HTML content here

\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/resend", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "resend" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "queue", + "item": [ + { + "name": "alter_table", + "item": [ + { + "name": "/queue/alter/add-column", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\"column_name\": \"group\", \"column_type\": \"TEXT\"}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/queue/empty", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "queue", + "empty" + ] + } + }, + "response": [] + }, + { + "name": "/queue/alter/rename_column", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "{\n \"old_name\": \"url\",\n \"new_name\": \"linkedin\",\n \"column_type\": \"TEXT\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/queue/alter/rename_column", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "queue", + "alter", + "rename_column" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "data", + "item": [ + { + "name": "queue/delete?id=YOUR_ID", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "" + }, + "url": { + "raw": "http://localhost:8000/queue/delete?id=YOUR_ID", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "queue", + "delete" + ], + "query": [ + { + "key": "id", + "value": "YOUR_ID" + } + ] + } + }, + "response": [] + }, + { + "name": "/queue/empty", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/queue/empty", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "queue", + "empty" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "import", + "item": [ + { + "name": "/queue/csv/linkedin", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/queue/import/linkedin", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "queue", + "import", + "linkedin" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "/queue", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/queue", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "queue" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "base_url", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "" + ] + } + }, + "response": [] + }, + { + "name": "8000/health", + "request": { + "auth": { + "type": "noauth" + }, + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/health", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "health" + ] + } + }, + "response": [] + } + ], + "description": "This folder contains requests targeting a local API running on **port 8000**. It covers the following areas:\n\n**Base & Health**\n\n- `GET /` — Verifies the base URL is reachable and the server is responding.\n \n- `GET /health` — Health check endpoint to confirm the API is running and operational.\n \n\n**Prospect Management**\n\n- `GET /prospects` — Retrieves the current list of prospects.\n \n- `GET /prospects/alter` — Fetches or triggers an alteration on prospect data.\n \n- `DELETE /prospects/empty` — Removes all prospects, emptying the prospects store.\n \n- `DELETE /prospects/empty` _(seed)_ — Used to reset and seed the prospects data, preparing the store with initial data for testing." + } + ] +} \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py index 8ddff71..8394cb4 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,4 @@ """Python - FastAPI, Postgres, tsvector""" # Current Version -__version__ = "2.2.5" +__version__ = "2.2.6" diff --git a/app/api/queue/__init__.py b/app/api/queue/__init__.py index 3a4711f..9705e10 100644 --- a/app/api/queue/__init__.py +++ b/app/api/queue/__init__.py @@ -4,7 +4,9 @@ from fastapi import APIRouter from .routes.drop import router as drop_router from .routes.empty import router as empty_router + from .routes.get import router as get_router +from .routes.next import router as next_router from .routes.create import router as create_router from .routes.delete import router as delete_router @@ -17,6 +19,7 @@ router.include_router(drop_router) router.include_router(empty_router) router.include_router(get_router) +router.include_router(next_router) router.include_router(create_router) router.include_router(delete_router) router.include_router(linkedin_import_router.router) diff --git a/app/api/queue/routes/next.py b/app/api/queue/routes/next.py new file mode 100644 index 0000000..b148c07 --- /dev/null +++ b/app/api/queue/routes/next.py @@ -0,0 +1,60 @@ +import os +from fastapi import APIRouter, HTTPException, Query +from app.utils.make_meta import make_meta +from app.utils.db import get_db_connection_direct + +router = APIRouter() + + +# Route: /queue/next?collection=prospects&group=linkedin +@router.get("/queue/next") +def get_next_queue( + collection: str = Query(None, description="Filter by collection name"), + group: str = Query(None, description="Filter by group name") +) -> dict: + """Return the next queue record filtered by collection/group, ordered by latest updated.""" + try: + conn = get_db_connection_direct() + cursor = conn.cursor() + + # Build query with optional filters + query = "SELECT * FROM queue" + filters = [] + params = [] + if collection: + filters.append("collection = %s") + params.append(collection) + if group: + filters.append('"group" = %s') + params.append(group) + if filters: + query += " WHERE " + " AND ".join(filters) + query += " ORDER BY updated DESC LIMIT 1;" + + cursor.execute(query, params) + row = cursor.fetchone() + columns = [desc[0] for desc in cursor.description] if cursor.description else [] + conn.close() + + if row and columns: + record = dict(zip(columns, row)) + # Build a dynamic title with filters + filters = [] + if collection: + filters.append(f"collection='{collection}'") + if group: + filters.append(f"group='{group}'") + filter_str = f" (filtered by {', '.join(filters)})" if filters else "" + title = f"Next queue record found{filter_str}" + return { + "meta": make_meta("success", title), + "data": record + } + else: + return { + "meta": make_meta("info", "No queue record to show"), + "data": None, + "message": "Nothing to show for the given filters." + } + except Exception as e: + raise HTTPException(status_code=500, detail=str(e))