{ "info": { "name": "Notifications — click-action flow", "description": "End-to-end test of the push-notification click-action mechanism: get an OAuth2 token, fetch a user's Gotify client_token from the notifications service, send a push with action_code/click_object_id_value (or a raw click_url), then read the message back directly from Gotify to confirm the click.url resolved correctly.\n\nRun folders top to bottom. Requests that produce a token/id set it into a collection variable automatically (see each request's Tests tab) so later requests don't need manual copy-pasting.\n\nImport `notifications-staging.postman_environment.json` alongside this collection and select it before running.", "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" }, "variable": [ { "key": "notifications_base_url", "value": "https://notifications-staging.gooyal.ir" }, { "key": "gotify_base_url", "value": "" }, { "key": "oauth2_token_url", "value": "" }, { "key": "client_id", "value": "" }, { "key": "client_secret", "value": "" }, { "key": "app_scopes", "value": "notifications.application.push:submit_message" }, { "key": "user_scopes", "value": "notifications.push:get_client_token" }, { "key": "user_username", "value": "" }, { "key": "user_password", "value": "" }, { "key": "app_access_token", "value": "" }, { "key": "user_access_token", "value": "" }, { "key": "user_uuid", "value": "" }, { "key": "client_token", "value": "" }, { "key": "application_token", "value": "" }, { "key": "gotify_since", "value": "0" } ], "item": [ { "name": "1. Auth", "item": [ { "name": "Get app access token (client_credentials)", "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/x-www-form-urlencoded" } ], "url": { "raw": "{{oauth2_token_url}}", "host": ["{{oauth2_token_url}}"] }, "body": { "mode": "urlencoded", "urlencoded": [ { "key": "grant_type", "value": "client_credentials" }, { "key": "client_id", "value": "{{client_id}}" }, { "key": "client_secret", "value": "{{client_secret}}" }, { "key": "scope", "value": "{{app_scopes}}" } ] }, "description": "This is the credential a PRODUCER service (chat/promotions/advertising) uses server-to-server to call the push endpoint. Fill in oauth2_token_url/client_id/client_secret from the producer application's OAuth2 client registered against the Gooyal accounts service. On success, the Tests script below stores the token as {{app_access_token}}." }, "event": [ { "listen": "test", "script": { "exec": [ "if (pm.response.code === 200) {", " const json = pm.response.json();", " pm.collectionVariables.set('app_access_token', json.access_token);", " pm.test('got app access_token', () => pm.expect(json.access_token).to.be.a('string'));", "} else {", " pm.test('token request failed: ' + pm.response.code, () => pm.expect.fail(pm.response.text()));", "}" ] } } ] }, { "name": "Get user access token (password grant)", "request": { "method": "POST", "header": [ { "key": "Content-Type", "value": "application/x-www-form-urlencoded" } ], "url": { "raw": "{{oauth2_token_url}}", "host": ["{{oauth2_token_url}}"] }, "body": { "mode": "urlencoded", "urlencoded": [ { "key": "grant_type", "value": "password" }, { "key": "username", "value": "{{user_username}}" }, { "key": "password", "value": "{{user_password}}" }, { "key": "client_id", "value": "{{client_id}}" }, { "key": "client_secret", "value": "{{client_secret}}" }, { "key": "scope", "value": "{{user_scopes}}" } ] }, "description": "This is a REAL logged-in Gooyal user's own token — what the mobile/web frontend would carry. Swap for whatever grant your accounts service actually issues client tokens with (this assumes password grant for a quick staging test; use authorization_code in the real app). On success, stores {{user_access_token}}." }, "event": [ { "listen": "test", "script": { "exec": [ "if (pm.response.code === 200) {", " const json = pm.response.json();", " pm.collectionVariables.set('user_access_token', json.access_token);", " pm.test('got user access_token', () => pm.expect(json.access_token).to.be.a('string'));", "} else {", " pm.test('token request failed: ' + pm.response.code, () => pm.expect.fail(pm.response.text()));", "}" ] } } ] } ] }, { "name": "2. Notifications service", "item": [ { "name": "Get my push_user (client_token)", "request": { "method": "GET", "header": [ { "key": "Authorization", "value": "Bearer {{user_access_token}}" } ], "url": { "raw": "{{notifications_base_url}}/push/user/push_user/", "host": ["{{notifications_base_url}}"], "path": ["push", "user", "push_user", ""] }, "description": "Called AS THE LOGGED-IN USER (user_access_token, not app_access_token). Lazily provisions the user's Gotify identity on first call. Returns client_token (frontend uses this to read its own messages from Gotify) and application_token (backend-only — a producer service shouldn't leak this to a client; see notes in the notifications docs). Also captures user_uuid for the send-push request below." }, "event": [ { "listen": "test", "script": { "exec": [ "if (pm.response.code === 200) {", " const json = pm.response.json();", " pm.collectionVariables.set('user_uuid', json.user);", " pm.collectionVariables.set('client_token', json.client_token);", " pm.collectionVariables.set('application_token', json.application_token);", " pm.test('has client_token', () => pm.expect(json.client_token).to.be.a('string'));", "} else {", " pm.test('request failed: ' + pm.response.code, () => pm.expect.fail(pm.response.text()));", "}" ] } } ] }, { "name": "Send push — action_code + click_object_id_value", "request": { "method": "POST", "header": [ { "key": "Authorization", "value": "Bearer {{app_access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "url": { "raw": "{{notifications_base_url}}/push/application/{{user_uuid}}/application/", "host": ["{{notifications_base_url}}"], "path": ["push", "application", "{{user_uuid}}", "application", ""] }, "body": { "mode": "raw", "raw": "{\n \"title\": \"Billboard approved\",\n \"message\": \"Tap to view your billboard\",\n \"priority\": 5,\n \"extras\": {},\n \"action_code\": \"billboard.approved\",\n \"click_object_id_value\": \"8fb638c2-e6a4-4baf-aefe-83dab78fb5bd\"\n}" }, "description": "Called AS THE PRODUCER SERVICE (app_access_token). Resolves action_code against the admin-managed NotificationLink table at send time — make sure that row exists and is active in staging (see the seed_notification_links management command), otherwise the message still sends but with no click_url. Response 201 means the PushMessage row was created and a Celery task was enqueued to actually deliver it to Gotify." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('push accepted (201)', () => pm.expect(pm.response.code).to.equal(201));" ] } } ] }, { "name": "Send push — raw click_url (alternative)", "request": { "method": "POST", "header": [ { "key": "Authorization", "value": "Bearer {{app_access_token}}" }, { "key": "Content-Type", "value": "application/json" } ], "url": { "raw": "{{notifications_base_url}}/push/application/{{user_uuid}}/application/", "host": ["{{notifications_base_url}}"], "path": ["push", "application", "{{user_uuid}}", "application", ""] }, "body": { "mode": "raw", "raw": "{\n \"title\": \"Order shipped\",\n \"message\": \"Tap to view your order\",\n \"priority\": 5,\n \"extras\": {},\n \"click_url\": \"https://app.gooyal.ir/orders/123\"\n}" }, "description": "click_url always wins over action_code if both are set. Useful to confirm the plumbing works independent of the NotificationLink admin table." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('push accepted (201)', () => pm.expect(pm.response.code).to.equal(201));" ] } } ] } ] }, { "name": "3. Gotify (read back)", "item": [ { "name": "Get my messages (client_token)", "request": { "method": "GET", "header": [ { "key": "X-Gotify-Key", "value": "{{client_token}}" } ], "url": { "raw": "{{gotify_base_url}}/message?limit=100", "host": ["{{gotify_base_url}}"], "path": ["message"], "query": [ { "key": "limit", "value": "100" } ] }, "description": "Reads Gotify DIRECTLY — not through the notifications service, which has no read endpoint of its own. Authenticates as the user via client_token (from step 2), so this only ever returns that user's own messages; Gotify enforces the isolation. Wait a second or two after sending for the Celery task to actually deliver before checking. Confirm extras[\"client::notification\"][\"click\"][\"url\"] matches what you expect from the action_code/click_url you sent." }, "event": [ { "listen": "test", "script": { "exec": [ "pm.test('200 OK', () => pm.expect(pm.response.code).to.equal(200));", "if (pm.response.code === 200) {", " const json = pm.response.json();", " console.log('messages:', JSON.stringify(json.messages, null, 2));", "}" ] } } ] }, { "name": "Get messages since a given id (incremental poll)", "request": { "method": "GET", "header": [ { "key": "X-Gotify-Key", "value": "{{client_token}}" } ], "url": { "raw": "{{gotify_base_url}}/message?since={{gotify_since}}&limit=100", "host": ["{{gotify_base_url}}"], "path": ["message"], "query": [ { "key": "since", "value": "{{gotify_since}}" }, { "key": "limit", "value": "100" } ] }, "description": "Same as above but only messages newer than gotify_since (a message id) — this is the shape a real client polling loop would use to avoid re-fetching everything. Set gotify_since to the paging.since value from a previous response to page forward." } } ] } ] }