From 6e4af2795df79872bc8fe3830660fa2156e8c82e Mon Sep 17 00:00:00 2001 From: sqsge Date: Sat, 18 Apr 2026 18:01:16 +0800 Subject: [PATCH 1/3] feat: issue oa_uid on first app_open --- .../docs/getting_started/tracking_links.md | 13 ++- apps/docs/docs/docs/todo/sdk.md | 11 +- .../api_app/controllers/postbacks.py | 32 +++-- apps/postback-api/api_app/models.py | 5 +- apps/postback-api/api_app/tools.py | 5 + apps/postback-api/tests/test_issue_233.py | 110 ++++++++++++++++++ 6 files changed, 163 insertions(+), 13 deletions(-) create mode 100644 apps/postback-api/tests/test_issue_233.py diff --git a/apps/docs/docs/docs/getting_started/tracking_links.md b/apps/docs/docs/docs/getting_started/tracking_links.md index ff9da36..0254063 100644 --- a/apps/docs/docs/docs/getting_started/tracking_links.md +++ b/apps/docs/docs/docs/getting_started/tracking_links.md @@ -19,7 +19,8 @@ ifa: The IFA (Identifier for Advertisers) of the user. event_id: The event ID from the attribution platform. event_time: The timestamp of the event in milliseconds since epoch. link_uid: The unique identifier for the link. -oa_uid: The unique identifier for the user. +oa_uid: The unique identifier for the user. This can be omitted for the first +app_open so the server can create and return one. ### In App IDs event_uid: The unique identifier for the event. This is a randomly generated UUID to deduplicate events. @@ -39,6 +40,14 @@ Content-Type: application/json "ifa": "00000000-0000-0000-0000-000000000000", "event_time": 1732003510046, "event_uid": "5730a99e-b009-41da-9d52-1315e26941c1", - "event_id": "app_open", + "event_id": "app_open" +} +``` + +Sample response for the first app_open: + +```json +{ "oa_uid": "3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2" } +``` diff --git a/apps/docs/docs/docs/todo/sdk.md b/apps/docs/docs/docs/todo/sdk.md index 0c1439a..b84f275 100644 --- a/apps/docs/docs/docs/todo/sdk.md +++ b/apps/docs/docs/docs/todo/sdk.md @@ -37,7 +37,14 @@ Content-Type: application/json "ifa": "00000000-0000-0000-0000-000000000000", "event_time": 1732003510046, "event_uid": "5730a99e-b009-41da-9d52-1315e26941c1", - "event_id": "app_open", + "event_id": "app_open" +} +``` + +The server response can then return an `oa_uid` for the SDK to store and reuse: + +```json +{ "oa_uid": "3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2" } ``` @@ -74,4 +81,4 @@ Content-Type: application/json - Apple AdAttributionKit support as a separate target. Apps prefer server control over conversion values and mappings. - Token Collection: - Apple attribution token for Apple Search Ads. - - Link query parameters for other ad networks, mainly for web-to-app transitions. \ No newline at end of file + - Link query parameters for other ad networks, mainly for web-to-app transitions. diff --git a/apps/postback-api/api_app/controllers/postbacks.py b/apps/postback-api/api_app/controllers/postbacks.py index 9c29bb0..64ce795 100644 --- a/apps/postback-api/api_app/controllers/postbacks.py +++ b/apps/postback-api/api_app/controllers/postbacks.py @@ -54,7 +54,14 @@ from api_app.models import ClickData, EventData, ImpressionData, RequestEventData from api_app.sendkafka import to_kafka -from api_app.tools import EMPTY_IFA, get_client_ip, is_valid_ifa, is_valid_uuid, now +from api_app.tools import ( + EMPTY_IFA, + generate_oa_uid, + get_client_ip, + is_valid_ifa, + is_valid_uuid, + now, +) logger = get_logger(__name__) @@ -293,7 +300,7 @@ async def events( request: Request, app: str, data: RequestEventData, - ) -> None: + ) -> dict[str, str]: """ Record event postbacks from in app. @@ -312,13 +319,15 @@ async def events( event_uid (str): A unique generated UID for the event, this is used for deduplication. ifa (str, optional): Identifier for Advertisers. revenue (str, optional): The numerical value of the revenue in USD. Examples: '1', '1.00', '0.222' - oa_uid (str): The unique ID for the user generated by the OpenAttribution SDK. + oa_uid (str, optional): The unique ID for the user generated by the OpenAttribution SDK. + If omitted for the first app_open, the server will generate one. } Returns: ------- - - None: The function does not return any value. + - dict[str, str]: The oa_uid used for the event. This lets the first + app_open receive a server-generated oa_uid. Behavior -------- @@ -335,11 +344,12 @@ async def events( Example Usage ------------- ``` - GET https://track.example.com/collect/events/com.example.app?event_id=level_1&event_time=1706499131&oa_uid=6a660ee7-bbc1-4440-9fdd-6564aca3560c&event_uid=6a660ee7-bbc1-4440-9fdd-6564aca3560c + POST https://track.example.com/collect/events/com.example.app ``` """ client_host = get_client_ip(request) + oa_uid = data.oa_uid ifa = data.ifa if not is_valid_ifa(ifa): if ifa is None: @@ -354,7 +364,14 @@ async def events( status_code=400, detail="Invalid event_uid format, use a v4 UUID", ) - if not is_valid_uuid(data.oa_uid): + if oa_uid is None: + if data.event_id != "app_open": + raise HTTPException( + status_code=400, + detail="Missing oa_uid, only first app_open may omit it", + ) + oa_uid = generate_oa_uid() + elif not is_valid_uuid(oa_uid): raise HTTPException( status_code=400, detail="Invalid oa_uid format, use a v4 UUID", @@ -372,7 +389,7 @@ async def events( DB_IFA: ifa, APP_EVENT_REV: data.revenue, DB_CLIENT_IP: client_host, - DB_OA_UID: data.oa_uid, + DB_OA_UID: oa_uid, DB_EVENT_UID: data.event_uid, DB_COUNTRY_ISO: country_iso, DB_STATE_ISO: state_iso, @@ -381,6 +398,7 @@ async def events( } event = EventData(**data) to_kafka(event, "events") + return {DB_OA_UID: oa_uid} @get(path="health") async def health(self: Self) -> dict: diff --git a/apps/postback-api/api_app/models.py b/apps/postback-api/api_app/models.py index b9650fc..533918f 100644 --- a/apps/postback-api/api_app/models.py +++ b/apps/postback-api/api_app/models.py @@ -176,7 +176,8 @@ class RequestEventData: event_id (str): The string ID for a tracked event (e.g., 'tutorial', 'level_1') event_time (int): The timestamp of the event event_uid (str): Unique identifier for event deduplication - oa_uid (str): Unique user ID generated by OpenAttribution SDK + oa_uid (Optional[str]): Unique user ID generated by OpenAttribution SDK. + This may be omitted for the first app_open so the server can issue one. ifa (Optional[str]): Identifier for Advertisers (optional) revenue (Optional[str]): Revenue value in USD (optional) @@ -185,6 +186,6 @@ class RequestEventData: event_id: str event_time: int event_uid: str - oa_uid: str + oa_uid: str | None = None ifa: str | None = None revenue: str | None = None diff --git a/apps/postback-api/api_app/tools.py b/apps/postback-api/api_app/tools.py index f87d83b..8975502 100644 --- a/apps/postback-api/api_app/tools.py +++ b/apps/postback-api/api_app/tools.py @@ -13,6 +13,11 @@ def generate_link_uid() -> str: return str(uuid.uuid4()) +def generate_oa_uid() -> str: + """Generate a random OpenAttribution user id.""" + return str(uuid.uuid4()) + + def is_valid_ifa(ifa: str | None) -> bool: """Check if a string is a valid ifa.""" if ifa is None: diff --git a/apps/postback-api/tests/test_issue_233.py b/apps/postback-api/tests/test_issue_233.py new file mode 100644 index 0000000..bb10670 --- /dev/null +++ b/apps/postback-api/tests/test_issue_233.py @@ -0,0 +1,110 @@ +"""Regression coverage for server-side oa_uid issuance on first app_open.""" + +import os +import unittest +from unittest.mock import Mock, patch +from uuid import UUID + +os.environ.setdefault("POSTGRES_USER", "postgres") +os.environ.setdefault("POSTGRES_DB", "postgres") +os.environ.setdefault("POSTGRES_PASSWORD", "postgres") +os.environ.setdefault("CLICKHOUSE_USER", "default") +os.environ.setdefault("CLICKHOUSE_PASSWORD", "password") + +from litestar import Litestar +from litestar.testing import TestClient + +from api_app.controllers.postbacks import PostbackController + + +class TestServerIssuedOaUid(unittest.TestCase): + """Focused API coverage for issue #233.""" + + def make_client(self) -> TestClient[Litestar]: + """Build a lightweight app with only the postback controller under test.""" + app = Litestar(route_handlers=[PostbackController], debug=True) + return TestClient(app=app) + + @patch( + "api_app.controllers.postbacks.get_geo", + return_value={"country_iso": "", "state_iso": "", "city_name": ""}, + ) + @patch("api_app.controllers.postbacks.to_kafka") + def test_first_app_open_without_oa_uid_returns_server_generated_id( + self, + to_kafka_mock: Mock, + _get_geo_mock: Mock, + ) -> None: + """The first app_open may omit oa_uid and receive one from the server.""" + payload = { + "event_id": "app_open", + "event_time": 1732003510046, + "event_uid": "5730a99e-b009-41da-9d52-1315e26941c1", + "ifa": "00000000-0000-0000-0000-000000000000", + } + + with self.make_client() as client: + response = client.post("/collect/events/com.example.app", json=payload) + + self.assertEqual(response.status_code, 201) + body = response.json() + self.assertIn("oa_uid", body) + self.assertEqual(str(UUID(body["oa_uid"])), body["oa_uid"]) + event_data, topic = to_kafka_mock.call_args.args + self.assertEqual(topic, "events") + self.assertEqual(event_data.oa_uid, body["oa_uid"]) + self.assertEqual(event_data.event_id, "app_open") + + @patch( + "api_app.controllers.postbacks.get_geo", + return_value={"country_iso": "", "state_iso": "", "city_name": ""}, + ) + @patch("api_app.controllers.postbacks.to_kafka") + def test_existing_client_supplied_oa_uid_still_works( + self, + to_kafka_mock: Mock, + _get_geo_mock: Mock, + ) -> None: + """Existing clients that already send oa_uid should remain compatible.""" + payload = { + "event_id": "tutorial", + "event_time": 1732003510046, + "event_uid": "5730a99e-b009-41da-9d52-1315e26941c1", + "oa_uid": "3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2", + "ifa": "00000000-0000-0000-0000-000000000000", + } + + with self.make_client() as client: + response = client.post("/collect/events/com.example.app", json=payload) + + self.assertEqual(response.status_code, 201) + self.assertEqual( + response.json(), + {"oa_uid": "3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2"}, + ) + event_data, topic = to_kafka_mock.call_args.args + self.assertEqual(topic, "events") + self.assertEqual(event_data.oa_uid, payload["oa_uid"]) + self.assertEqual(event_data.event_id, "tutorial") + + def test_non_app_open_without_oa_uid_is_rejected(self) -> None: + """Only the first app_open may rely on server-side issuance.""" + payload = { + "event_id": "tutorial", + "event_time": 1732003510046, + "event_uid": "5730a99e-b009-41da-9d52-1315e26941c1", + "ifa": "00000000-0000-0000-0000-000000000000", + } + + with self.make_client() as client: + response = client.post("/collect/events/com.example.app", json=payload) + + self.assertEqual(response.status_code, 400) + self.assertEqual( + response.json()["detail"], + "Missing oa_uid, only first app_open may omit it", + ) + + +if __name__ == "__main__": + unittest.main() From 24a4359340d1cd29ae4f9e888221fd5c624acf57 Mon Sep 17 00:00:00 2001 From: sqsge Date: Sat, 18 Apr 2026 18:30:16 +0800 Subject: [PATCH 2/3] Make first app_open oa_uid issuance idempotent --- apps/admin-db/pg-init.sql | 11 ++- .../api_app/controllers/postbacks.py | 8 +- apps/postback-api/api_app/oa_uid.py | 50 ++++++++++ apps/postback-api/dbcon/queries.py | 33 +++++++ .../dbcon/sql/insert_oa_uid_issuance.sql | 14 +++ .../dbcon/sql/query_oa_uid_issuance.sql | 4 + apps/postback-api/tests/test_issue_233.py | 10 ++ apps/postback-api/tests/test_oa_uid.py | 99 +++++++++++++++++++ 8 files changed, 226 insertions(+), 3 deletions(-) create mode 100644 apps/postback-api/api_app/oa_uid.py create mode 100644 apps/postback-api/dbcon/sql/insert_oa_uid_issuance.sql create mode 100644 apps/postback-api/dbcon/sql/query_oa_uid_issuance.sql create mode 100644 apps/postback-api/tests/test_oa_uid.py diff --git a/apps/admin-db/pg-init.sql b/apps/admin-db/pg-init.sql index eef9aad..c35631e 100644 --- a/apps/admin-db/pg-init.sql +++ b/apps/admin-db/pg-init.sql @@ -60,6 +60,16 @@ CREATE TABLE app_links ( ); +CREATE TABLE oa_uid_issuances ( + id SERIAL PRIMARY KEY, + event_uid UUID UNIQUE NOT NULL, + oa_uid UUID NOT NULL, + store_id VARCHAR(100) NOT NULL, + ifa UUID NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + + INSERT INTO networks (name, postback_id, status, is_custom) VALUES ('Google','google', 'inactive', FALSE), ('Meta', 'meta', 'inactive', FALSE), @@ -70,4 +80,3 @@ INSERT INTO networks (name, postback_id, status, is_custom) VALUES ('Social Media Posts', 'customsocial', 'inactive', TRUE) ; - diff --git a/apps/postback-api/api_app/controllers/postbacks.py b/apps/postback-api/api_app/controllers/postbacks.py index 64ce795..b9264c2 100644 --- a/apps/postback-api/api_app/controllers/postbacks.py +++ b/apps/postback-api/api_app/controllers/postbacks.py @@ -53,10 +53,10 @@ from litestar.params import Parameter from api_app.models import ClickData, EventData, ImpressionData, RequestEventData +from api_app.oa_uid import issue_oa_uid_for_first_open from api_app.sendkafka import to_kafka from api_app.tools import ( EMPTY_IFA, - generate_oa_uid, get_client_ip, is_valid_ifa, is_valid_uuid, @@ -370,7 +370,11 @@ async def events( status_code=400, detail="Missing oa_uid, only first app_open may omit it", ) - oa_uid = generate_oa_uid() + oa_uid = issue_oa_uid_for_first_open( + event_uid=data.event_uid, + store_id=app, + ifa=ifa, + ) elif not is_valid_uuid(oa_uid): raise HTTPException( status_code=400, diff --git a/apps/postback-api/api_app/oa_uid.py b/apps/postback-api/api_app/oa_uid.py new file mode 100644 index 0000000..fb7ccdd --- /dev/null +++ b/apps/postback-api/api_app/oa_uid.py @@ -0,0 +1,50 @@ +"""Helpers for issuing stable oa_uid values on first app_open.""" + +from api_app.tools import generate_oa_uid + + +def query_issued_oa_uid(event_uid: str) -> str | None: + """Return any oa_uid already issued for this event uid.""" + from dbcon.queries import query_oa_uid_issuance + + return query_oa_uid_issuance(event_uid=event_uid) + + +def insert_issued_oa_uid( + event_uid: str, + oa_uid: str, + store_id: str, + ifa: str, +) -> str | None: + """Persist a newly issued oa_uid, unless another request won the race.""" + from dbcon.queries import insert_oa_uid_issuance + + return insert_oa_uid_issuance( + event_uid=event_uid, + oa_uid=oa_uid, + store_id=store_id, + ifa=ifa, + ) + + +def issue_oa_uid_for_first_open(event_uid: str, store_id: str, ifa: str) -> str: + """Issue or reuse an oa_uid for a first app_open request.""" + existing_oa_uid = query_issued_oa_uid(event_uid=event_uid) + if existing_oa_uid is not None: + return existing_oa_uid + + new_oa_uid = generate_oa_uid() + inserted_oa_uid = insert_issued_oa_uid( + event_uid=event_uid, + oa_uid=new_oa_uid, + store_id=store_id, + ifa=ifa, + ) + if inserted_oa_uid is not None: + return inserted_oa_uid + + existing_oa_uid = query_issued_oa_uid(event_uid=event_uid) + if existing_oa_uid is None: + msg = f"Unable to issue oa_uid for first app_open {event_uid=}" + raise ValueError(msg) + return existing_oa_uid diff --git a/apps/postback-api/dbcon/queries.py b/apps/postback-api/dbcon/queries.py index efb06af..d9d67e8 100644 --- a/apps/postback-api/dbcon/queries.py +++ b/apps/postback-api/dbcon/queries.py @@ -29,6 +29,8 @@ def load_sql_file(file_name: str) -> str: QUERY_APP_LINKS = load_sql_file( "app_links.sql", ) +QUERY_OA_UID_ISSUANCE = load_sql_file("query_oa_uid_issuance.sql") +INSERT_OA_UID_ISSUANCE = load_sql_file("insert_oa_uid_issuance.sql") async def get_app_links() -> dict[str, dict[str, str]]: @@ -72,6 +74,37 @@ async def get_apps() -> pd.DataFrame: return df +def query_oa_uid_issuance(event_uid: str) -> str | None: + """Return any oa_uid already issued for a first app_open event.""" + with ENGINE.connect() as connection: + result = connection.execute( + QUERY_OA_UID_ISSUANCE, + {"event_uid": event_uid}, + ).scalar_one_or_none() + return result + + +def insert_oa_uid_issuance( + event_uid: str, + oa_uid: str, + store_id: str, + ifa: str, +) -> str | None: + """Insert a new issuance record, unless another request already did so.""" + with ENGINE.connect() as connection: + result = connection.execute( + INSERT_OA_UID_ISSUANCE, + { + "event_uid": event_uid, + "oa_uid": oa_uid, + "store_id": store_id, + "ifa": ifa, + }, + ).scalar_one_or_none() + connection.commit() + return result + + logger.info("set db engine") DBCON = get_db_connection() DBCON.set_engine() diff --git a/apps/postback-api/dbcon/sql/insert_oa_uid_issuance.sql b/apps/postback-api/dbcon/sql/insert_oa_uid_issuance.sql new file mode 100644 index 0000000..2b82b93 --- /dev/null +++ b/apps/postback-api/dbcon/sql/insert_oa_uid_issuance.sql @@ -0,0 +1,14 @@ +INSERT INTO oa_uid_issuances ( + event_uid, + oa_uid, + store_id, + ifa +) +VALUES ( + :event_uid, + :oa_uid, + :store_id, + :ifa +) +ON CONFLICT (event_uid) DO NOTHING +RETURNING oa_uid; diff --git a/apps/postback-api/dbcon/sql/query_oa_uid_issuance.sql b/apps/postback-api/dbcon/sql/query_oa_uid_issuance.sql new file mode 100644 index 0000000..4b746e7 --- /dev/null +++ b/apps/postback-api/dbcon/sql/query_oa_uid_issuance.sql @@ -0,0 +1,4 @@ +SELECT oa_uid +FROM oa_uid_issuances +WHERE event_uid = :event_uid +LIMIT 1; diff --git a/apps/postback-api/tests/test_issue_233.py b/apps/postback-api/tests/test_issue_233.py index bb10670..79cacd0 100644 --- a/apps/postback-api/tests/test_issue_233.py +++ b/apps/postback-api/tests/test_issue_233.py @@ -29,10 +29,15 @@ def make_client(self) -> TestClient[Litestar]: "api_app.controllers.postbacks.get_geo", return_value={"country_iso": "", "state_iso": "", "city_name": ""}, ) + @patch( + "api_app.controllers.postbacks.issue_oa_uid_for_first_open", + return_value="3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2", + ) @patch("api_app.controllers.postbacks.to_kafka") def test_first_app_open_without_oa_uid_returns_server_generated_id( self, to_kafka_mock: Mock, + issue_oa_uid_mock: Mock, _get_geo_mock: Mock, ) -> None: """The first app_open may omit oa_uid and receive one from the server.""" @@ -50,6 +55,11 @@ def test_first_app_open_without_oa_uid_returns_server_generated_id( body = response.json() self.assertIn("oa_uid", body) self.assertEqual(str(UUID(body["oa_uid"])), body["oa_uid"]) + issue_oa_uid_mock.assert_called_once_with( + event_uid=payload["event_uid"], + store_id="com.example.app", + ifa=payload["ifa"], + ) event_data, topic = to_kafka_mock.call_args.args self.assertEqual(topic, "events") self.assertEqual(event_data.oa_uid, body["oa_uid"]) diff --git a/apps/postback-api/tests/test_oa_uid.py b/apps/postback-api/tests/test_oa_uid.py new file mode 100644 index 0000000..1e8fb8a --- /dev/null +++ b/apps/postback-api/tests/test_oa_uid.py @@ -0,0 +1,99 @@ +"""Unit coverage for stable first-open oa_uid issuance.""" + +import os +import unittest +from unittest.mock import patch + +os.environ.setdefault("POSTGRES_USER", "postgres") +os.environ.setdefault("POSTGRES_DB", "postgres") +os.environ.setdefault("POSTGRES_PASSWORD", "postgres") +os.environ.setdefault("CLICKHOUSE_USER", "default") +os.environ.setdefault("CLICKHOUSE_PASSWORD", "password") + +from api_app.oa_uid import issue_oa_uid_for_first_open + + +class TestIssueOaUidForFirstOpen(unittest.TestCase): + """Verify issuance is stable across retries and insertion races.""" + + @patch("api_app.oa_uid.insert_issued_oa_uid") + @patch( + "api_app.oa_uid.generate_oa_uid", + return_value="1f8d66fe-4a2d-4da9-95e8-d54cdbaf4d6b", + ) + @patch("api_app.oa_uid.query_issued_oa_uid", return_value=None) + def test_creates_new_oa_uid_when_none_exists( + self, + query_mock, + _generate_mock, + insert_mock, + ) -> None: + """The first request inserts and returns a fresh oa_uid.""" + insert_mock.return_value = "1f8d66fe-4a2d-4da9-95e8-d54cdbaf4d6b" + + oa_uid = issue_oa_uid_for_first_open( + event_uid="5730a99e-b009-41da-9d52-1315e26941c1", + store_id="com.example.app", + ifa="00000000-0000-0000-0000-000000000000", + ) + + self.assertEqual(oa_uid, "1f8d66fe-4a2d-4da9-95e8-d54cdbaf4d6b") + insert_mock.assert_called_once() + query_mock.assert_called_once() + + @patch("api_app.oa_uid.insert_issued_oa_uid") + @patch("api_app.oa_uid.generate_oa_uid") + @patch( + "api_app.oa_uid.query_issued_oa_uid", + return_value="3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2", + ) + def test_reuses_existing_oa_uid_for_retried_first_open( + self, + query_mock, + generate_mock, + insert_mock, + ) -> None: + """A retried first app_open returns the original issued oa_uid.""" + oa_uid = issue_oa_uid_for_first_open( + event_uid="5730a99e-b009-41da-9d52-1315e26941c1", + store_id="com.example.app", + ifa="00000000-0000-0000-0000-000000000000", + ) + + self.assertEqual(oa_uid, "3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2") + generate_mock.assert_not_called() + insert_mock.assert_not_called() + query_mock.assert_called_once() + + @patch( + "api_app.oa_uid.query_issued_oa_uid", + side_effect=[ + None, + "3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2", + ], + ) + @patch( + "api_app.oa_uid.generate_oa_uid", + return_value="1f8d66fe-4a2d-4da9-95e8-d54cdbaf4d6b", + ) + @patch("api_app.oa_uid.insert_issued_oa_uid", return_value=None) + def test_reuses_existing_oa_uid_after_insert_race( + self, + insert_mock, + _generate_mock, + query_mock, + ) -> None: + """If another request wins the insert race, return the stored oa_uid.""" + oa_uid = issue_oa_uid_for_first_open( + event_uid="5730a99e-b009-41da-9d52-1315e26941c1", + store_id="com.example.app", + ifa="00000000-0000-0000-0000-000000000000", + ) + + self.assertEqual(oa_uid, "3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2") + insert_mock.assert_called_once() + self.assertEqual(query_mock.call_count, 2) + + +if __name__ == "__main__": + unittest.main() From f7770aae9f7cd41decd95fa8d1e4aa5b6b6a06c5 Mon Sep 17 00:00:00 2001 From: sqsge Date: Sat, 18 Apr 2026 19:08:11 +0800 Subject: [PATCH 3/3] Normalize issued oa_uid values from Postgres --- apps/postback-api/api_app/oa_uid.py | 5 +++++ apps/postback-api/dbcon/queries.py | 5 +++-- apps/postback-api/tests/test_oa_uid.py | 18 +++++++++++++++++- 3 files changed, 25 insertions(+), 3 deletions(-) diff --git a/apps/postback-api/api_app/oa_uid.py b/apps/postback-api/api_app/oa_uid.py index fb7ccdd..6ec7ea7 100644 --- a/apps/postback-api/api_app/oa_uid.py +++ b/apps/postback-api/api_app/oa_uid.py @@ -3,6 +3,11 @@ from api_app.tools import generate_oa_uid +def normalize_oa_uid_result(result: object | None) -> str | None: + """Normalize persisted oa_uid values to strings at the boundary.""" + return None if result is None else str(result) + + def query_issued_oa_uid(event_uid: str) -> str | None: """Return any oa_uid already issued for this event uid.""" from dbcon.queries import query_oa_uid_issuance diff --git a/apps/postback-api/dbcon/queries.py b/apps/postback-api/dbcon/queries.py index d9d67e8..db115e8 100644 --- a/apps/postback-api/dbcon/queries.py +++ b/apps/postback-api/dbcon/queries.py @@ -4,6 +4,7 @@ from typing import cast import pandas as pd +from api_app.oa_uid import normalize_oa_uid_result from api_app.models import AppStores from config import MODULE_DIR, get_logger from litestar.stores.memory import MemoryStore @@ -81,7 +82,7 @@ def query_oa_uid_issuance(event_uid: str) -> str | None: QUERY_OA_UID_ISSUANCE, {"event_uid": event_uid}, ).scalar_one_or_none() - return result + return normalize_oa_uid_result(result) def insert_oa_uid_issuance( @@ -102,7 +103,7 @@ def insert_oa_uid_issuance( }, ).scalar_one_or_none() connection.commit() - return result + return normalize_oa_uid_result(result) logger.info("set db engine") diff --git a/apps/postback-api/tests/test_oa_uid.py b/apps/postback-api/tests/test_oa_uid.py index 1e8fb8a..badf62b 100644 --- a/apps/postback-api/tests/test_oa_uid.py +++ b/apps/postback-api/tests/test_oa_uid.py @@ -2,6 +2,7 @@ import os import unittest +from uuid import UUID from unittest.mock import patch os.environ.setdefault("POSTGRES_USER", "postgres") @@ -10,7 +11,7 @@ os.environ.setdefault("CLICKHOUSE_USER", "default") os.environ.setdefault("CLICKHOUSE_PASSWORD", "password") -from api_app.oa_uid import issue_oa_uid_for_first_open +from api_app.oa_uid import issue_oa_uid_for_first_open, normalize_oa_uid_result class TestIssueOaUidForFirstOpen(unittest.TestCase): @@ -95,5 +96,20 @@ def test_reuses_existing_oa_uid_after_insert_race( self.assertEqual(query_mock.call_count, 2) +class TestNormalizeOaUidResult(unittest.TestCase): + """Verify DB helper boundaries always normalize UUID values to strings.""" + + def test_normalize_oa_uid_result_returns_none_for_none(self) -> None: + """None should remain None.""" + self.assertIsNone(normalize_oa_uid_result(None)) + + def test_normalize_oa_uid_result_converts_uuid_to_string(self) -> None: + """UUID objects should be converted to string values.""" + result = normalize_oa_uid_result( + UUID("3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2"), + ) + self.assertEqual(result, "3bd9e091-fa6e-4b91-8dd1-503f8d4fe8f2") + + if __name__ == "__main__": unittest.main()