From b8506c6f4234feb5d2d781c762c3f8d18b56ecc2 Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Tue, 28 Apr 2026 06:04:05 +0100 Subject: [PATCH 1/2] Bump version and relocate notify API, add tests Bump package version to 3.0.5 and refactor notify utilities into app/api/notify (renamed files and updated imports). Update API routes to import the relocated resend router. Replace/modify Postman collection (tests/Postman.json) with renamed endpoints, method/body adjustments and added requests. Add new tests/test_github.py with unit tests for /github and related admin endpoints using TestClient and mocked DB connections. --- app/__init__.py | 2 +- app/{utils => api}/notify/__init__.py | 0 app/{utils => api}/notify/resend.py | 2 +- app/{utils => api}/notify/send_email.py | 0 app/api/routes.py | 2 +- .../Postman.json | 224 ++++++++++++++++-- tests/test_github.py | 149 ++++++++++++ 7 files changed, 353 insertions(+), 26 deletions(-) rename app/{utils => api}/notify/__init__.py (100%) rename app/{utils => api}/notify/resend.py (97%) rename app/{utils => api}/notify/send_email.py (100%) rename "tests/Python\302\260.json" => tests/Postman.json (75%) create mode 100644 tests/test_github.py diff --git a/app/__init__.py b/app/__init__.py index 08e1d23..bb3a17a 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -1,4 +1,4 @@ """Python° - FastAPI, Postgres, tsvector""" # Current Version -__version__ = "3.0.4" +__version__ = "3.0.5" diff --git a/app/utils/notify/__init__.py b/app/api/notify/__init__.py similarity index 100% rename from app/utils/notify/__init__.py rename to app/api/notify/__init__.py diff --git a/app/utils/notify/resend.py b/app/api/notify/resend.py similarity index 97% rename from app/utils/notify/resend.py rename to app/api/notify/resend.py index 4007c35..13b2452 100644 --- a/app/utils/notify/resend.py +++ b/app/api/notify/resend.py @@ -4,7 +4,7 @@ from app.utils.make_meta import make_meta from fastapi import APIRouter, Query, Path, Body, HTTPException from app.utils.db import get_db_connection -from app.utils.notify.send_email import send_email_resend +from app.api.notify.send_email import send_email_resend router = APIRouter() base_url = os.getenv("BASE_URL", "http://localhost:8000") diff --git a/app/utils/notify/send_email.py b/app/api/notify/send_email.py similarity index 100% rename from app/utils/notify/send_email.py rename to app/api/notify/send_email.py diff --git a/app/api/routes.py b/app/api/routes.py index 8af471d..02d29d3 100644 --- a/app/api/routes.py +++ b/app/api/routes.py @@ -7,7 +7,7 @@ from app.api.root import router as root_router from app.utils.health import router as health_router -from app.utils.notify.resend import router as resend_router +from app.api.notify.resend import router as resend_router from app.api.prompt.prompt import router as prompt_router from app.api.prompt.empty import router as prompts_empty_router from app.api.prompt.delete_id import router as prompt_delete_id_router diff --git "a/tests/Python\302\260.json" b/tests/Postman.json similarity index 75% rename from "tests/Python\302\260.json" rename to tests/Postman.json index c7ab72a..bf86c72 100644 --- "a/tests/Python\302\260.json" +++ b/tests/Postman.json @@ -36,16 +36,19 @@ "name": "prompts", "item": [ { - "name": "8K/prompt/linkedin", + "name": "8K/prompt", + "protocolProfileBehavior": { + "disableBodyPruning": true + }, "request": { "auth": { "type": "noauth" }, - "method": "POST", + "method": "GET", "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}", + "raw": "", "options": { "raw": { "language": "json" @@ -53,15 +56,14 @@ } }, "url": { - "raw": "http://localhost:8000/prompt/linkedin", + "raw": "http://localhost:8000/prompt", "protocol": "http", "host": [ "localhost" ], "port": "8000", "path": [ - "prompt", - "linkedin" + "prompt" ] } }, @@ -69,18 +71,15 @@ }, { "name": "8K/prompt", - "protocolProfileBehavior": { - "disableBodyPruning": true - }, "request": { "auth": { "type": "noauth" }, - "method": "GET", + "method": "POST", "header": [], "body": { "mode": "raw", - "raw": "", + "raw": "{\n \"prompt\": \"This is a test prompt from Postman. Tell me why Postman is good for developing APIs\"\n}", "options": { "raw": { "language": "json" @@ -102,15 +101,50 @@ "response": [] }, { - "name": "8K/prompt/dump", - "protocolProfileBehavior": { - "disableBodyPruning": true + "name": "Delete ID", + "request": { + "auth": { + "type": "noauth" + }, + "method": "DELETE", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/prompt/delete_id?id=321", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "prompt", + "delete_id" + ], + "query": [ + { + "key": "id", + "value": "321" + } + ] + } }, + "response": [] + }, + { + "name": "8K/prompt/empty", "request": { "auth": { "type": "noauth" }, - "method": "GET", + "method": "PATCH", "header": [], "body": { "mode": "raw", @@ -122,7 +156,7 @@ } }, "url": { - "raw": "http://localhost:8000/prompt/dump", + "raw": "http://localhost:8000/prompt/empty", "protocol": "http", "host": [ "localhost" @@ -130,7 +164,7 @@ "port": "8000", "path": [ "prompt", - "dump" + "empty" ] } }, @@ -397,7 +431,7 @@ "header": [], "body": { "mode": "raw", - "raw": "{\"column_name\": \"group\", \"column_type\": \"TEXT\"}", + "raw": "{\"column_name\": \"seniority\", \"column_type\": \"TEXT\"}", "options": { "raw": { "language": "json" @@ -405,7 +439,7 @@ } }, "url": { - "raw": "http://localhost:8000/queue/empty", + "raw": "http://localhost:8000/queue/alter/add-column", "protocol": "http", "host": [ "localhost" @@ -413,7 +447,8 @@ "port": "8000", "path": [ "queue", - "empty" + "alter", + "add-column" ] } }, @@ -521,11 +556,30 @@ } }, "response": [] + }, + { + "name": "queue", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/queue", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "queue" + ] + } + }, + "response": [] } ] }, { - "name": "import", + "name": "csv", "item": [ { "name": "/queue/csv/linkedin", @@ -545,7 +599,7 @@ } }, "url": { - "raw": "http://localhost:8000/queue/import/linkedin", + "raw": "http://localhost:8000/queue/csv/linkedin", "protocol": "http", "host": [ "localhost" @@ -553,12 +607,45 @@ "port": "8000", "path": [ "queue", - "import", + "csv", "linkedin" ] } }, "response": [] + }, + { + "name": "/queue/csv/apollo", + "request": { + "auth": { + "type": "noauth" + }, + "method": "POST", + "header": [], + "body": { + "mode": "raw", + "raw": "", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "http://localhost:8000/queue/csv/apollo", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "queue", + "csv", + "apollo" + ] + } + }, + "response": [] } ] }, @@ -586,6 +673,97 @@ } ] }, + { + "name": "GitHub", + "item": [ + { + "name": "Postgres", + "item": [ + { + "name": "Empty Tables", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "http://localhost:8000/api/github/empty", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "api", + "github", + "empty" + ] + } + }, + "response": [] + }, + { + "name": "Create Tables", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "http://localhost:8000/github/createtable", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "github", + "createtable" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Get", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "http://localhost:8000/github", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "github" + ] + } + }, + "response": [] + }, + { + "name": "Sync", + "request": { + "method": "POST", + "header": [], + "url": { + "raw": "http://localhost:8000/api/github/sync", + "protocol": "http", + "host": [ + "localhost" + ], + "port": "8000", + "path": [ + "api", + "github", + "sync" + ] + } + }, + "response": [] + } + ] + }, { "name": "base_url", "request": { diff --git a/tests/test_github.py b/tests/test_github.py new file mode 100644 index 0000000..277a4d4 --- /dev/null +++ b/tests/test_github.py @@ -0,0 +1,149 @@ +from unittest.mock import MagicMock, patch +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + +_TABLES = [ + "github_accounts", + "github_repos", + "github_gists", + "github_projects", + "github_resources", +] + + +class _MockCursor: + def __init__(self, fetchone_values, fetchall_values=None): + self._fetchone_values = list(fetchone_values) + self._fetchall_values = list(fetchall_values or []) + self.executed = [] + self.description = [("id",), ("name",)] + + def execute(self, query, params=None): + self.executed.append((query, params)) + + def fetchone(self): + if self._fetchone_values: + return self._fetchone_values.pop(0) + return None + + def fetchall(self): + if self._fetchall_values: + return self._fetchall_values.pop(0) + return [] + + def close(self): + pass + + +class _MockConnection: + def __init__(self, fetchone_values, fetchall_values=None): + self.cursor_obj = _MockCursor(fetchone_values, fetchall_values) + self.committed = False + self.rolled_back = False + + def cursor(self): + return self.cursor_obj + + def commit(self): + self.committed = True + + def rollback(self): + self.rolled_back = True + + def close(self): + pass + + +# --------------------------------------------------------------------------- +# GET /github +# --------------------------------------------------------------------------- + +def test_github_get_returns_all_tables(monkeypatch): + # One COUNT fetchone per table, one fetchall of rows per table. + conn = _MockConnection( + fetchone_values=[(3,), (7,), (0,), (1,), (2,)], + fetchall_values=[ + [(1, "Alex Milky")], # github_accounts rows + [(10, "fastapi-starter")], # github_repos rows + [], # github_gists rows (empty) + [(30, "API Roadmap 2026")], # github_projects rows + [(40, "some resource")], # github_resources rows + ], + ) + + from app.api.github import github as github_module + + monkeypatch.setattr(github_module, "get_db_connection_direct", lambda: conn) + + response = client.get("/github") + assert response.status_code == 200 + + payload = response.json() + assert payload["meta"]["severity"] == "success" + assert set(payload["data"].keys()) == set(_TABLES) + + assert payload["data"]["github_accounts"]["count"] == 3 + assert payload["data"]["github_repos"]["count"] == 7 + assert payload["data"]["github_gists"]["count"] == 0 + assert payload["data"]["github_gists"]["rows"] == [] + assert payload["data"]["github_projects"]["count"] == 1 + assert payload["data"]["github_resources"]["count"] == 2 + + +def test_github_get_returns_error_on_db_failure(monkeypatch): + from app.api.github import github as github_module + + monkeypatch.setattr( + github_module, + "get_db_connection_direct", + lambda: (_ for _ in ()).throw(Exception("connection refused")), + ) + + response = client.get("/github") + assert response.status_code == 200 + + payload = response.json() + assert payload["meta"]["severity"] == "error" + assert payload["data"] == {} + + +# --------------------------------------------------------------------------- +# POST /api/github/empty +# --------------------------------------------------------------------------- + +def test_github_empty_truncates_all_tables(monkeypatch): + conn = _MockConnection(fetchone_values=[]) + + from app.api.github.sql import empty_tables as empty_module + + monkeypatch.setattr(empty_module, "get_db_connection_direct", lambda: conn) + + response = client.post("/api/github/empty") + assert response.status_code == 200 + + payload = response.json() + assert payload["meta"]["severity"] == "success" + assert set(payload["data"]["tables"]) == set(_TABLES) + assert conn.committed is True + + +# --------------------------------------------------------------------------- +# POST /api/github/createtable +# --------------------------------------------------------------------------- + +def test_github_create_tables_succeeds(monkeypatch): + conn = _MockConnection(fetchone_values=[]) + + from app.api.github.sql import create_tables as create_module + + monkeypatch.setattr(create_module, "get_db_connection_direct", lambda: conn) + + response = client.post("/api/github/createtable") + assert response.status_code == 200 + + payload = response.json() + assert payload["meta"]["severity"] == "success" + assert conn.committed is True From 6dc3117c43d0eb620dde5c0e998bc9abd08e4c96 Mon Sep 17 00:00:00 2001 From: Wei Zang Date: Tue, 28 Apr 2026 06:16:36 +0100 Subject: [PATCH 2/2] Add Goldlabel email template and use in resend Introduce a Goldlabel-branded HTML email template and apply it to resend emails. Adds app/utils/email_templates with goldlabel.py (goldlabel_email) and __init__.py export, and updates app/api/notify/resend.py to wrap outgoing HTML with the goldlabel_email template before sending, ensuring consistent branded emails. --- app/api/notify/resend.py | 3 +- app/utils/email_templates/__init__.py | 3 + app/utils/email_templates/goldlabel.py | 130 +++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 1 deletion(-) create mode 100644 app/utils/email_templates/__init__.py create mode 100644 app/utils/email_templates/goldlabel.py diff --git a/app/api/notify/resend.py b/app/api/notify/resend.py index 13b2452..80df88a 100644 --- a/app/api/notify/resend.py +++ b/app/api/notify/resend.py @@ -5,6 +5,7 @@ from fastapi import APIRouter, Query, Path, Body, HTTPException from app.utils.db import get_db_connection from app.api.notify.send_email import send_email_resend +from app.utils.email_templates import goldlabel_email router = APIRouter() base_url = os.getenv("BASE_URL", "http://localhost:8000") @@ -62,7 +63,7 @@ def send_email(request: EmailRequest): result = send_email_resend( to=request.to, subject=request.subject, - html=request.html + html=goldlabel_email(request.subject, request.html), ) if "error" in result: meta = make_meta("error", result["error"]) diff --git a/app/utils/email_templates/__init__.py b/app/utils/email_templates/__init__.py new file mode 100644 index 0000000..d27101f --- /dev/null +++ b/app/utils/email_templates/__init__.py @@ -0,0 +1,3 @@ +"""Email templates""" + +from .goldlabel import goldlabel_email diff --git a/app/utils/email_templates/goldlabel.py b/app/utils/email_templates/goldlabel.py new file mode 100644 index 0000000..0f49e59 --- /dev/null +++ b/app/utils/email_templates/goldlabel.py @@ -0,0 +1,130 @@ +"""Goldlabel branded HTML email template.""" + +_LOGO_URL = "https://goldlabel.pro/goldlabelpro/png/favicon.png" + +# Palette (dark theme used for the email chrome; body text stays readable on white clients) +_DARK_BG = "#364450" +_DARK_PAPER = "#364450" +_DARK_PRIMARY = "#ffd849" +_DARK_TEXT = "#ffffff" +_LIGHT_BG = "#eaf0f5" +_LIGHT_PAPER = "#EEF7FF" +_LIGHT_TEXT = "#000000" +_LIGHT_PRIMARY = "#364450" + + +def goldlabel_email(subject: str, body_html: str) -> str: + """Return a complete HTML email string with Goldlabel branding. + + Args: + subject: Used as the visible heading inside the email. + body_html: Inner HTML content placed in the message body area. + + Returns: + A self-contained HTML string ready to pass to send_email_resend(). + """ + return f""" + + + + + + + {subject} + + + + + + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +"""