From 57728aae26a8585483dd935d46cc3384aa77d6d6 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 7 Oct 2026 12:45:41 +0600 Subject: [PATCH 1/2] badges: match DID-object subjects and recipient responses --- .../bright-badges-match-did-subjects.md | 5 + api/lua/endpoints/getBadgeAward.lua | 2 + api/lua/endpoints/listBadgeAwards.lua | 12 +- api/lua/src/getBadgeAward.lua | 2 + api/lua/src/listBadgeAwards.lua | 12 +- api/tests/http/badge-queries.http.test.js | 115 ++++++++++++++++-- .../http/fixtures/badge-queries.fixture.js | 37 +++--- api/tests/unit/tooling/badge-queries.test.js | 65 +++++++++- 8 files changed, 216 insertions(+), 34 deletions(-) create mode 100644 .changeset/bright-badges-match-did-subjects.md diff --git a/.changeset/bright-badges-match-did-subjects.md b/.changeset/bright-badges-match-did-subjects.md new file mode 100644 index 0000000..8b879d6 --- /dev/null +++ b/.changeset/bright-badges-match-did-subjects.md @@ -0,0 +1,5 @@ +--- +'@hypercerts-org/hypercerts-api': patch +--- + +Badge award filters and response status lookups now recognize DID-object recipients while retaining record-subject and legacy DID matching. diff --git a/api/lua/endpoints/getBadgeAward.lua b/api/lua/endpoints/getBadgeAward.lua index d89dea4..2810967 100644 --- a/api/lua/endpoints/getBadgeAward.lua +++ b/api/lua/endpoints/getBadgeAward.lua @@ -102,6 +102,8 @@ end local function recipient_did_expression() local subject = "award.record::jsonb->'subject'" return "CASE WHEN jsonb_typeof(" .. subject .. ") = 'string' THEN award.record::jsonb->>'subject' " .. + "WHEN jsonb_typeof(" .. subject .. ") = 'object' AND " .. subject .. "->>'$type' = 'app.certified.defs#did' " .. + "AND jsonb_typeof(" .. subject .. "->'did') = 'string' THEN " .. subject .. "->>'did' " .. "ELSE split_part(" .. subject .. "->>'uri', '/', 3) END" end diff --git a/api/lua/endpoints/listBadgeAwards.lua b/api/lua/endpoints/listBadgeAwards.lua index 17d7474..3c61598 100644 --- a/api/lua/endpoints/listBadgeAwards.lua +++ b/api/lua/endpoints/listBadgeAwards.lua @@ -237,8 +237,14 @@ local function add_subject_filter(where, values, subjects) values[#values + 1] = did placeholders[#placeholders + 1] = "$" .. #values end - clauses[#clauses + 1] = "(jsonb_typeof(award.record::jsonb->'subject') = 'string' AND " .. - "award.record::jsonb->>'subject' IN (" .. table.concat(placeholders, ",") .. "))" + local did_values = " IN (" .. table.concat(placeholders, ",") .. ")" + local subject = "award.record::jsonb->'subject'" + clauses[#clauses + 1] = "(jsonb_typeof(" .. subject .. ") = 'string' AND " .. + "award.record::jsonb->>'subject'" .. did_values .. ")" + clauses[#clauses + 1] = "(jsonb_typeof(" .. subject .. ") = 'object' AND " .. + subject .. "->>'$type' = 'app.certified.defs#did' AND " .. + "jsonb_typeof(" .. subject .. "->'did') = 'string' AND " .. + subject .. "->>'did'" .. did_values .. ")" end if #subjects.uris > 0 then local placeholders = {} @@ -281,6 +287,8 @@ end local function recipient_did_expression() local subject = "award.record::jsonb->'subject'" return "CASE WHEN jsonb_typeof(" .. subject .. ") = 'string' THEN award.record::jsonb->>'subject' " .. + "WHEN jsonb_typeof(" .. subject .. ") = 'object' AND " .. subject .. "->>'$type' = 'app.certified.defs#did' " .. + "AND jsonb_typeof(" .. subject .. "->'did') = 'string' THEN " .. subject .. "->>'did' " .. "ELSE split_part(" .. subject .. "->>'uri', '/', 3) END" end diff --git a/api/lua/src/getBadgeAward.lua b/api/lua/src/getBadgeAward.lua index 1ccd56f..94ecf56 100644 --- a/api/lua/src/getBadgeAward.lua +++ b/api/lua/src/getBadgeAward.lua @@ -19,6 +19,8 @@ end local function recipient_did_expression() local subject = "award.record::jsonb->'subject'" return "CASE WHEN jsonb_typeof(" .. subject .. ") = 'string' THEN award.record::jsonb->>'subject' " .. + "WHEN jsonb_typeof(" .. subject .. ") = 'object' AND " .. subject .. "->>'$type' = 'app.certified.defs#did' " .. + "AND jsonb_typeof(" .. subject .. "->'did') = 'string' THEN " .. subject .. "->>'did' " .. "ELSE split_part(" .. subject .. "->>'uri', '/', 3) END" end diff --git a/api/lua/src/listBadgeAwards.lua b/api/lua/src/listBadgeAwards.lua index e5a2ce0..79b2adb 100644 --- a/api/lua/src/listBadgeAwards.lua +++ b/api/lua/src/listBadgeAwards.lua @@ -112,8 +112,14 @@ local function add_subject_filter(where, values, subjects) values[#values + 1] = did placeholders[#placeholders + 1] = "$" .. #values end - clauses[#clauses + 1] = "(jsonb_typeof(award.record::jsonb->'subject') = 'string' AND " .. - "award.record::jsonb->>'subject' IN (" .. table.concat(placeholders, ",") .. "))" + local did_values = " IN (" .. table.concat(placeholders, ",") .. ")" + local subject = "award.record::jsonb->'subject'" + clauses[#clauses + 1] = "(jsonb_typeof(" .. subject .. ") = 'string' AND " .. + "award.record::jsonb->>'subject'" .. did_values .. ")" + clauses[#clauses + 1] = "(jsonb_typeof(" .. subject .. ") = 'object' AND " .. + subject .. "->>'$type' = 'app.certified.defs#did' AND " .. + "jsonb_typeof(" .. subject .. "->'did') = 'string' AND " .. + subject .. "->>'did'" .. did_values .. ")" end if #subjects.uris > 0 then local placeholders = {} @@ -156,6 +162,8 @@ end local function recipient_did_expression() local subject = "award.record::jsonb->'subject'" return "CASE WHEN jsonb_typeof(" .. subject .. ") = 'string' THEN award.record::jsonb->>'subject' " .. + "WHEN jsonb_typeof(" .. subject .. ") = 'object' AND " .. subject .. "->>'$type' = 'app.certified.defs#did' " .. + "AND jsonb_typeof(" .. subject .. "->'did') = 'string' THEN " .. subject .. "->>'did' " .. "ELSE split_part(" .. subject .. "->>'uri', '/', 3) END" end diff --git a/api/tests/http/badge-queries.http.test.js b/api/tests/http/badge-queries.http.test.js index e637b84..3efd1f5 100644 --- a/api/tests/http/badge-queries.http.test.js +++ b/api/tests/http/badge-queries.http.test.js @@ -1,7 +1,7 @@ import test from 'node:test'; import assert from 'node:assert/strict'; import { contractUrl, requireContractTarget } from './helpers.js'; -import { badgeQueryDids, canonicalRecords } from './fixtures/badge-queries.fixture.js'; +import { badgeQueryDids, canonicalCids, canonicalRecords } from './fixtures/badge-queries.fixture.js'; const endpoints = { searchBadgeDefinitions: 'app.certified.badge.searchBadgeDefinitions', @@ -139,17 +139,48 @@ test('getBadgeAward retrieves the exact award version and latest eligible recipi assert.equal(response.status, 200, JSON.stringify(body)); const { badgeAward } = body; assert.equal(badgeAward.uri, canonicalRecords.awardA.uri); - assert.equal(badgeAward.cid, 'bafyreibkpebxfngi6cangcci7sozlkqsgfrgxu2a2ebcdu2f7cutgkxgke'); + assert.equal(badgeAward.cid, canonicalCids.awardA); assert.deepEqual(badgeAward.record, canonicalRecords.awardA.record); assert.equal(badgeAward.badge.uri, definitionUri); assert.equal(badgeAward.badge.cid, 'bafyreiatp3bngjbyu5skyqanyt3nr7riaxsmyok73bsuzn2sz52i2oft5a'); assert.deepEqual(badgeAward.badge.record, canonicalRecords.definitionA.record); assert.equal(badgeAward.responseStatus, 'accepted'); - assert.equal(badgeAward.recipientResponse.uri, canonicalRecords.responseAccepted.uri); - assert.equal(badgeAward.recipientResponse.cid, 'bafyreidsmg5v62mympmzotqptqnkiksjaqc3de2naugadgx7zmbdgycjue'); + assert.equal(badgeAward.recipientResponse.uri, canonicalRecords.responseNewestAccepted.uri); + assert.equal(badgeAward.recipientResponse.cid, canonicalCids.responseNewestAccepted); + assert.equal(badgeAward.recipientResponse.indexedAt, canonicalRecords.responseNewestAccepted.indexedAt); + assert.equal(badgeAward.recipientResponse.record.badgeAward.uri, canonicalRecords.awardA.uri); + assert.equal(badgeAward.recipientResponse.record.badgeAward.cid, canonicalCids.awardA); assert.equal(badgeAward.author.profile.record.displayName, 'Cedar Query Publisher'); assert.equal(badgeAward.author.organization, null); + const recordRecipient = await requestBadgeEndpoint(endpoints.getBadgeAward, { + uri: canonicalRecords.awardB.uri, + }); + assert.equal(recordRecipient.response.status, 200, JSON.stringify(recordRecipient.body)); + assert.equal(recordRecipient.body.badgeAward.responseStatus, 'rejected'); + assert.equal(recordRecipient.body.badgeAward.recipientResponse.uri, canonicalRecords.responseRecordRecipient.uri); + + const bareStringDid = await requestBadgeEndpoint(endpoints.getBadgeAward, { + uri: canonicalRecords.awardPartner.uri, + }); + assert.equal(bareStringDid.response.status, 200, JSON.stringify(bareStringDid.body)); + assert.equal(bareStringDid.body.badgeAward.uri, canonicalRecords.awardPartner.uri); + assert.equal(bareStringDid.body.badgeAward.cid, canonicalCids.awardPartner); + assert.equal(bareStringDid.body.badgeAward.author.did, badgeQueryDids.partner); + assert.equal(bareStringDid.body.badgeAward.responseStatus, 'accepted'); + assert.equal(bareStringDid.body.badgeAward.recipientResponse.uri, canonicalRecords.responseBareStringDid.uri); + assert.equal(bareStringDid.body.badgeAward.recipientResponse.cid, canonicalCids.responseBareStringDid); + assert.equal(bareStringDid.body.badgeAward.recipientResponse.did, badgeQueryDids.recordRecipient); + assert.equal(bareStringDid.body.badgeAward.recipientResponse.record.badgeAward.uri, canonicalRecords.awardPartner.uri); + assert.equal(bareStringDid.body.badgeAward.recipientResponse.record.badgeAward.cid, canonicalCids.awardPartner); + + const unansweredObjectDid = await requestBadgeEndpoint(endpoints.getBadgeAward, { + uri: canonicalRecords.awardC.uri, + }); + assert.equal(unansweredObjectDid.response.status, 200, JSON.stringify(unansweredObjectDid.body)); + assert.equal(unansweredObjectDid.body.badgeAward.responseStatus, 'unanswered'); + assert.equal(unansweredObjectDid.body.badgeAward.recipientResponse, null); + const unavailableVersion = await requestBadgeEndpoint(endpoints.getBadgeAward, { uri: canonicalRecords.awardD.uri, }); @@ -176,21 +207,31 @@ test('listBadgeAwards returns the full feed with exact-version and actor-sidecar canonicalRecords.awardB.uri, canonicalRecords.awardC.uri, canonicalRecords.awardD.uri, + canonicalRecords.awardOtherAuthor.uri, canonicalRecords.awardPartner.uri, ]); const awardA = body.badgeAwards.find(({ uri }) => uri === canonicalRecords.awardA.uri); assert.equal(awardA.badge.record.title, 'Cedar Query Badge A'); assert.equal(awardA.responseStatus, 'accepted'); + assert.equal(awardA.recipientResponse.uri, canonicalRecords.responseNewestAccepted.uri); + assert.equal(awardA.recipientResponse.cid, canonicalCids.responseNewestAccepted); + assert.equal(awardA.recipientResponse.record.badgeAward.uri, canonicalRecords.awardA.uri); + assert.equal(awardA.recipientResponse.record.badgeAward.cid, canonicalCids.awardA); assert.equal(awardA.author.profile.record.displayName, 'Cedar Query Publisher'); assert.equal(awardA.author.organization, null); const awardC = body.badgeAwards.find(({ uri }) => uri === canonicalRecords.awardC.uri); assert.equal(awardC.responseStatus, 'unanswered'); assert.equal(awardC.recipientResponse, null); + const awardB = body.badgeAwards.find(({ uri }) => uri === canonicalRecords.awardB.uri); + assert.equal(awardB.responseStatus, 'rejected'); + assert.equal(awardB.recipientResponse.uri, canonicalRecords.responseRecordRecipient.uri); const awardD = body.badgeAwards.find(({ uri }) => uri === canonicalRecords.awardD.uri); assert.equal(awardD.badge, null); const partnerAward = body.badgeAwards.find(({ uri }) => uri === canonicalRecords.awardPartner.uri); assert.equal(partnerAward.author.profile, null); assert.equal(partnerAward.author.organization.record.visibility, 'public'); + assert.equal(partnerAward.responseStatus, 'accepted'); + assert.equal(partnerAward.recipientResponse.uri, canonicalRecords.responseBareStringDid.uri); }); test('listBadgeAwards combines discriminating filters and traverses ties in both directions', async () => { @@ -215,12 +256,39 @@ test('listBadgeAwards combines discriminating filters and traverses ties in both canonicalRecords.awardB.uri, ]); - const acceptedRecipient = await requestBadgeEndpoint(endpoints.listBadgeAwards, { + const otherAuthorSubjectMatch = await requestBadgeEndpoint(endpoints.listBadgeAwards, { + authors: [badgeQueryDids.partner], + badgeUris: [definitionUri], + badgeTypes: ['certification'], + subjects: [recordSubjectUri], + responses: ['accepted', 'rejected'], + }); + assert.equal(otherAuthorSubjectMatch.response.status, 200, JSON.stringify(otherAuthorSubjectMatch.body)); + assert.deepEqual(otherAuthorSubjectMatch.body.badgeAwards.map(({ uri }) => uri), [canonicalRecords.awardOtherAuthor.uri]); + + const legacyBareDid = await requestBadgeEndpoint(endpoints.listBadgeAwards, { + authors: [badgeQueryDids.partner], + badgeUris: [partnerDefinitionUri], + subjects: [badgeQueryDids.recordRecipient], + responses: ['accepted'], + }); + assert.equal(legacyBareDid.response.status, 200, JSON.stringify(legacyBareDid.body)); + assert.deepEqual(legacyBareDid.body.badgeAwards.map(({ uri }) => uri), [canonicalRecords.awardPartner.uri]); + const bareStringDidAward = legacyBareDid.body.badgeAwards[0]; + assert.equal(bareStringDidAward.cid, canonicalCids.awardPartner); + assert.equal(bareStringDidAward.responseStatus, 'accepted'); + assert.equal(bareStringDidAward.recipientResponse.uri, canonicalRecords.responseBareStringDid.uri); + assert.equal(bareStringDidAward.recipientResponse.did, badgeQueryDids.recordRecipient); + assert.equal(bareStringDidAward.recipientResponse.cid, canonicalCids.responseBareStringDid); + assert.equal(bareStringDidAward.recipientResponse.record.badgeAward.uri, canonicalRecords.awardPartner.uri); + assert.equal(bareStringDidAward.recipientResponse.record.badgeAward.cid, canonicalCids.awardPartner); + + const supersededAcceptedRecipient = await requestBadgeEndpoint(endpoints.listBadgeAwards, { authors: [badgeQueryDids.publisher], badgeUris: [definitionUri], subjects: [badgeQueryDids.recipient], responses: ['accepted'], }); - assert.equal(acceptedRecipient.response.status, 200, JSON.stringify(acceptedRecipient.body)); - assert.deepEqual(acceptedRecipient.body.badgeAwards.map(({ uri }) => uri), [canonicalRecords.awardA.uri]); + assert.equal(supersededAcceptedRecipient.response.status, 200, JSON.stringify(supersededAcceptedRecipient.body)); + assert.deepEqual(supersededAcceptedRecipient.body.badgeAwards.map(({ uri }) => uri), [canonicalRecords.awardA.uri]); const unmatchedFilters = [ { authors: [badgeQueryDids.partner] }, @@ -262,7 +330,7 @@ test('getBadgeResponse retrieves a raw response with its independent CBOR CID an assert.equal(response.status, 200, JSON.stringify(body)); const { badgeResponse } = body; assert.equal(badgeResponse.uri, canonicalRecords.responseAccepted.uri); - assert.equal(badgeResponse.cid, 'bafyreidsmg5v62mympmzotqptqnkiksjaqc3de2naugadgx7zmbdgycjue'); + assert.equal(badgeResponse.cid, canonicalCids.responseAccepted); assert.deepEqual(badgeResponse.record, canonicalRecords.responseAccepted.record); assert.equal(badgeResponse.record.response, 'accepted'); assert.equal(badgeResponse.author.did, badgeQueryDids.recipient); @@ -290,23 +358,48 @@ test('listBadgeResponses preserves the full raw feed, URI-only matching, and bid [...expectedAll].sort(), ); - const uriFiltered = ['responseAccepted', 'responseOlderAwardVersion', 'responseDeferred', 'responseForeign'] + const uriFiltered = ['responseAccepted', 'responseOlderAwardVersion', 'responseDeferred', 'responseRejected', 'responseNewestAccepted', 'responseForeign'] .map((name) => canonicalRecords[name].uri); await assertBidirectionalPages(endpoints.listBadgeResponses, 'badgeResponses', { badgeAward: awardUri }, { - asc: [[uriFiltered[0], uriFiltered[1]], [uriFiltered[2], uriFiltered[3]]], - desc: [[uriFiltered[3], uriFiltered[2]], [uriFiltered[1], uriFiltered[0]]], + asc: [[uriFiltered[0], uriFiltered[1]], [uriFiltered[2], uriFiltered[3]], [uriFiltered[4], uriFiltered[5]]], + desc: [[uriFiltered[5], uriFiltered[4]], [uriFiltered[3], uriFiltered[2]], [uriFiltered[1], uriFiltered[0]]], }); const firstPage = await requestBadgeEndpoint(endpoints.listBadgeResponses, { badgeAward: awardUri, sortDirection: 'asc', limit: 100, }); assert.equal(firstPage.response.status, 200, JSON.stringify(firstPage.body)); + const newestEligible = firstPage.body.badgeResponses.find(({ uri }) => uri === canonicalRecords.responseNewestAccepted.uri); + assert.equal(newestEligible.record.badgeAward.uri, canonicalRecords.awardA.uri); + assert.equal(newestEligible.record.badgeAward.cid, canonicalCids.awardA); + assert.equal(newestEligible.author.did, badgeQueryDids.recipient); + assert.equal(newestEligible.record.response, 'accepted'); + assert.equal(newestEligible.indexedAt, canonicalRecords.responseNewestAccepted.indexedAt); + const earlierEligibleRejected = firstPage.body.badgeResponses.find(({ uri }) => uri === canonicalRecords.responseRejected.uri); + assert.equal(earlierEligibleRejected.author.did, badgeQueryDids.recipient); + assert.equal(earlierEligibleRejected.record.badgeAward.uri, canonicalRecords.awardA.uri); + assert.equal(earlierEligibleRejected.record.badgeAward.cid, canonicalCids.awardA); + assert.equal(earlierEligibleRejected.record.response, 'rejected'); + assert.ok(Date.parse(earlierEligibleRejected.indexedAt) < Date.parse(newestEligible.indexedAt)); + for (const name of ['responseForeign', 'responseOlderAwardVersion', 'responseDeferred']) { + const decoy = firstPage.body.badgeResponses.find(({ uri }) => uri === canonicalRecords[name].uri); + assert.ok(Date.parse(decoy.indexedAt) > Date.parse(newestEligible.indexedAt), `${name} must be indexed after the newest eligible response`); + } const olderAwardVersion = firstPage.body.badgeResponses.find(({ uri }) => uri === canonicalRecords.responseOlderAwardVersion.uri); + assert.equal(olderAwardVersion.author.did, badgeQueryDids.recipient); + assert.equal(olderAwardVersion.record.badgeAward.uri, canonicalRecords.awardA.uri); assert.equal(olderAwardVersion.record.badgeAward.cid, 'bafyre-old-award-version'); assert.equal(olderAwardVersion.record.response, 'rejected'); const deferred = firstPage.body.badgeResponses.find(({ uri }) => uri === canonicalRecords.responseDeferred.uri); + assert.equal(deferred.author.did, badgeQueryDids.recipient); + assert.equal(deferred.record.badgeAward.uri, canonicalRecords.awardA.uri); + assert.equal(deferred.record.badgeAward.cid, canonicalCids.awardA); assert.equal(deferred.record.response, 'deferred'); const foreign = firstPage.body.badgeResponses.find(({ uri }) => uri === canonicalRecords.responseForeign.uri); + assert.equal(foreign.record.badgeAward.uri, canonicalRecords.awardA.uri); + assert.equal(foreign.record.badgeAward.cid, canonicalCids.awardA); + assert.equal(foreign.record.response, 'rejected'); + assert.equal(foreign.author.did, badgeQueryDids.unlistedAuthor); assert.equal(foreign.author.profile, null); assert.equal(foreign.author.organization, null); const accepted = firstPage.body.badgeResponses.find(({ uri }) => uri === canonicalRecords.responseAccepted.uri); diff --git a/api/tests/http/fixtures/badge-queries.fixture.js b/api/tests/http/fixtures/badge-queries.fixture.js index 45b2b10..77453d7 100644 --- a/api/tests/http/fixtures/badge-queries.fixture.js +++ b/api/tests/http/fixtures/badge-queries.fixture.js @@ -64,7 +64,7 @@ async function cidsFor(records) { const definitionCids = await cidsFor(definitions); const awardA = record(collections.award, badgeQueryDids.publisher, 'query-award-a', { badge: { uri: definitions.definitionA.uri, cid: definitionCids.definitionA }, - subject: badgeQueryDids.recipient, + subject: { $type: 'app.certified.defs#did', did: badgeQueryDids.recipient }, createdAt: awardCreatedAt, }); const awardB = record(collections.award, badgeQueryDids.publisher, 'query-award-b', { @@ -78,7 +78,7 @@ const awardB = record(collections.award, badgeQueryDids.publisher, 'query-award- }); const awardC = record(collections.award, badgeQueryDids.publisher, 'query-award-c', { badge: { uri: definitions.definitionA.uri, cid: definitionCids.definitionA }, - subject: badgeQueryDids.recipient, + subject: { $type: 'app.certified.defs#did', did: badgeQueryDids.recipient }, createdAt: awardCreatedAt, }); const awardD = record(collections.award, badgeQueryDids.publisher, 'query-award-d', { @@ -91,25 +91,33 @@ const awardPartner = record(collections.award, badgeQueryDids.partner, 'query-aw subject: badgeQueryDids.recordRecipient, createdAt: '2025-03-04T00:00:00.000Z', }); +const awardOtherAuthor = record(collections.award, badgeQueryDids.partner, 'query-award-other-author', { + badge: { uri: definitions.definitionA.uri, cid: definitionCids.definitionA }, + subject: awardB.record.subject, + createdAt: '2025-03-03T00:00:00.000Z', +}); -const awardCids = await cidsFor({ awardA, awardB, awardC, awardD, awardPartner }); -const response = (did, rkey, award, cid, status, createdAt = responseCreatedAt) => record( - collections.response, - did, - rkey, - { +const awardCids = await cidsFor({ awardA, awardB, awardC, awardD, awardPartner, awardOtherAuthor }); +const response = (did, rkey, award, cid, status, createdAt = responseCreatedAt, rowIndexedAt = indexedAt) => ({ + ...record(collections.response, did, rkey, { badgeAward: { $type: 'com.atproto.repo.strongRef', uri: award.uri, cid }, response: status, createdAt, - }, -); + }), + indexedAt: rowIndexedAt, +}); const responses = { responseAccepted: response(badgeQueryDids.recipient, 'query-response-a', awardA, awardCids.awardA, 'accepted'), - responseOlderAwardVersion: response(badgeQueryDids.recipient, 'query-response-b', awardA, 'bafyre-old-award-version', 'rejected'), - responseDeferred: response(badgeQueryDids.recipient, 'query-response-c', awardA, awardCids.awardA, 'deferred'), - responseForeign: response(badgeQueryDids.unlistedAuthor, 'query-response-d', awardA, awardCids.awardA, 'rejected'), + responseOlderAwardVersion: response(badgeQueryDids.recipient, 'query-response-b', awardA, 'bafyre-old-award-version', 'rejected', responseCreatedAt, '2025-04-05T00:00:00.000Z'), + responseDeferred: response(badgeQueryDids.recipient, 'query-response-c', awardA, awardCids.awardA, 'deferred', responseCreatedAt, '2025-04-06T00:00:00.000Z'), + responseForeign: response(badgeQueryDids.unlistedAuthor, 'query-response-d', awardA, awardCids.awardA, 'rejected', responseCreatedAt, '2025-04-04T00:00:00.000Z'), + responseRejected: response(badgeQueryDids.recipient, 'query-response-e', awardA, awardCids.awardA, 'rejected', responseCreatedAt, '2025-04-02T00:00:00.000Z'), + responseNewestAccepted: response(badgeQueryDids.recipient, 'query-response-f', awardA, awardCids.awardA, 'accepted', responseCreatedAt, '2025-04-03T00:00:00.000Z'), responseRecordRecipient: response(badgeQueryDids.recordRecipient, 'query-response-record-recipient', awardB, awardCids.awardB, 'rejected'), + responseBareStringDid: response(badgeQueryDids.recordRecipient, 'query-response-bare-string-did', awardPartner, awardCids.awardPartner, 'accepted'), + responseBareStringDidWrongAuthor: response(badgeQueryDids.unlistedAuthor, 'query-response-bare-string-did-wrong-author', awardPartner, awardCids.awardPartner, 'rejected', responseCreatedAt, '2025-04-02T00:00:00.000Z'), + responseOtherAuthorAward: response(badgeQueryDids.recordRecipient, 'query-response-other-author-award', awardOtherAuthor, awardCids.awardOtherAuthor, 'accepted'), responseWrongRecipient: response(badgeQueryDids.unlistedAuthor, 'query-response-wrong-recipient', awardC, awardCids.awardC, 'accepted'), }; @@ -132,6 +140,7 @@ export const canonicalRecords = { awardC, awardD, awardPartner, + awardOtherAuthor, ...responses, ...actors, }; @@ -141,5 +150,5 @@ export const canonicalCids = await cidsFor(canonicalRecords); export const seedRows = Object.entries(canonicalRecords).map(([name, item]) => ({ ...item, cid: canonicalCids[name], - indexedAt, + indexedAt: item.indexedAt ?? indexedAt, })); diff --git a/api/tests/unit/tooling/badge-queries.test.js b/api/tests/unit/tooling/badge-queries.test.js index f4dd9fe..d8d3167 100644 --- a/api/tests/unit/tooling/badge-queries.test.js +++ b/api/tests/unit/tooling/badge-queries.test.js @@ -246,6 +246,7 @@ assert(result.badgeAward.author.profile.record.displayName == 'Issuer') local sql = calls[1].sql assert(sql:find("badge.cid = award.record::jsonb->'badge'->>'cid'", 1, true)) assert(sql:find("response.did = CASE WHEN jsonb_typeof(award.record::jsonb->'subject') = 'string'", 1, true)) +assert(sql:find("WHEN jsonb_typeof(award.record::jsonb->'subject') = 'object' AND award.record::jsonb->'subject'->>'$type' = 'app.certified.defs#did' AND jsonb_typeof(award.record::jsonb->'subject'->'did') = 'string' THEN award.record::jsonb->'subject'->>'did'", 1, true)) assert(sql:find("split_part(award.record::jsonb->'subject'->>'uri', '/', 3)", 1, true)) assert(sql:find("response.record::jsonb->'badgeAward'->>'uri' = award.uri", 1, true)) assert(sql:find("response.record::jsonb->'badgeAward'->>'cid' = award.cid", 1, true)) @@ -265,6 +266,47 @@ assert(not sql:find("response.record::jsonb->>'createdAt'", 1, true), 'publisher }); }); +test('getBadgeAward resolves a DID-object recipient and constrains response author and exact award version', async () => { + const recipient = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; + const awardUri = `at://${issuer}/${awardCollection}/did-object`; + const responseUri = `at://${recipient}/${responseCollection}/did-object`; + const awardCid = 'bafy-did-object-award-v1'; + const row = { + uri: awardUri, did: issuer, cid: awardCid, indexed_at: indexedAt, record: 'did-object-award', + record_json: { + $type: awardCollection, + badge: { uri: `at://${issuer}/${definitionCollection}/forest`, cid: 'bafy-definition-v1' }, + subject: { $type: 'app.certified.defs#did', did: recipient }, + createdAt: '2025-01-01T00:00:00Z', + }, + recipient_response_uri: responseUri, recipient_response_cid: 'bafy-did-object-response-v1', + recipient_response_indexed_at: indexedAt, recipient_response_did: recipient, + recipient_response_record: 'did-object-response', + }; + + runLua(await handlerSource('getBadgeAward'), { + params: { uri: awardUri }, + queryResults: [[row], [], []], + recordValues: { + 'did-object-response': { + $type: responseCollection, + badgeAward: { uri: awardUri, cid: awardCid }, + response: 'accepted', + }, + }, + assertions: ` +assert(result.badgeAward.responseStatus == 'accepted') +assert(result.badgeAward.recipientResponse.uri == '${responseUri}') +assert(result.badgeAward.recipientResponse.did == '${recipient}') +local sql = calls[1].sql +assert(sql:find("WHEN jsonb_typeof(award.record::jsonb->'subject') = 'object' AND award.record::jsonb->'subject'->>'$type' = 'app.certified.defs#did' AND jsonb_typeof(award.record::jsonb->'subject'->'did') = 'string' THEN award.record::jsonb->'subject'->>'did'", 1, true)) +assert(sql:find("response.did = CASE WHEN", 1, true)) +assert(sql:find("response.record::jsonb->'badgeAward'->>'uri' = award.uri", 1, true)) +assert(sql:find("response.record::jsonb->'badgeAward'->>'cid' = award.cid", 1, true)) +`, + }); +}); + test('listBadgeAwards combines filters, keeps repeated awards, and returns recipient status with keyset pagination', async () => { const secondIssuer = 'did:plc:cccccccccccccccccccccccc'; const recipient = 'did:plc:bbbbbbbbbbbbbbbbbbbbbbbb'; @@ -287,7 +329,7 @@ test('listBadgeAwards combines filters, keeps repeated awards, and returns recip sort_timestamp: createdAt.replace('Z', '.000000Z'), }); const rows = [ - awardRow(awardUri('actor-1'), issuer, 'actor-one', recipient, '2025-01-01T00:00:00Z', { + awardRow(awardUri('actor-1'), issuer, 'actor-one', { $type: 'app.certified.defs#did', did: recipient }, '2025-01-01T00:00:00Z', { uri: `at://${recipient}/${responseCollection}/actor-1`, cid: 'bafy-response-1', did: recipient, record: 'response-1', }), awardRow(`at://${secondIssuer}/${awardCollection}/actor-2`, secondIssuer, 'actor-two', recipient, '2025-01-02T00:00:00Z'), @@ -310,13 +352,13 @@ test('listBadgeAwards combines filters, keeps repeated awards, and returns recip runLua(await handlerSource('listBadgeAwards'), { params: { authors: [issuer, secondIssuer], badgeUris: [badgeUri], badgeTypes: ['certification'], - subjects: [recipient, subjectRecord], responses: ['accepted', 'unanswered'], sortDirection: 'asc', limit: '3', + subjects: [recipient, subjectRecord], responses: ['accepted', 'rejected', 'unanswered'], sortDirection: 'asc', limit: '3', }, queryResults: [rows, profiles, []], recordValues: { 'badge-record': { $type: definitionCollection, title: 'Forest Badge', badgeType: 'certification' }, 'response-1': responseRecord(awardUri('actor-1'), 'bafy-actor-one', 'accepted'), - 'response-record': responseRecord(awardUri('record-1'), 'bafy-record-one', 'accepted'), + 'response-record': responseRecord(awardUri('record-1'), 'bafy-record-one', 'rejected'), }, assertions: ` assert(#result.badgeAwards == 3 and result.cursor ~= nil) @@ -329,7 +371,7 @@ assert(result.badgeAwards[1].recipientResponse.indexedAt == '${indexedAt}') assert(result.badgeAwards[1].responseStatus == 'accepted') assert(result.badgeAwards[2].responseStatus == 'unanswered' and result.badgeAwards[2].recipientResponse == NULL_VALUE) assert(result.badgeAwards[3].record.subject.uri == '${subjectRecord}') -assert(result.badgeAwards[3].responseStatus == 'accepted' and result.badgeAwards[3].recipientResponse.did == '${recordOwner}') +assert(result.badgeAwards[3].responseStatus == 'rejected' and result.badgeAwards[3].recipientResponse.did == '${recordOwner}') assert(result.badgeAwards[1].author.profile.record.displayName == 'Issuer One') local cursor = json.decode(result.cursor) assert(cursor.v == 1 and cursor.d == 'asc' and cursor.u == '${rows[2].uri}') @@ -340,7 +382,20 @@ assert(sql:find("badge.record::jsonb->>'badgeType' IN", 1, true)) assert(sql:find("badge.cid = award.record::jsonb->'badge'->>'cid'", 1, true)) assert(sql:find("jsonb_typeof(award.record::jsonb->'subject') = 'string'", 1, true)) assert(sql:find("jsonb_typeof(award.record::jsonb->'subject') = 'object'", 1, true)) +assert(sql:find("award.record::jsonb->'subject'->>'$type' = 'app.certified.defs#did'", 1, true)) +assert(sql:find("jsonb_typeof(award.record::jsonb->'subject'->'did') = 'string'", 1, true)) +assert(sql:find("award.record::jsonb->'subject'->>'did' IN", 1, true)) assert(sql:find("award.record::jsonb->'subject'->>'uri' IN", 1, true)) +local subject_did = sql:find("award.record::jsonb->'subject'->>'did' IN", 1, true) +local subject_or = sql:find(" OR ", subject_did, true) +local subject_uri = sql:find("award.record::jsonb->'subject'->>'uri' IN", subject_or, true) +local other_filter = sql:find(") AND badge.record::jsonb->>'badgeType' IN", subject_uri, true) +assert(subject_did and subject_or and subject_uri and other_filter + and subject_did < subject_or and subject_or < subject_uri and subject_uri < other_filter, + 'DID and URI subject alternatives are ORed before the other ANDed filters') +assert(sql:find("response.did = CASE WHEN jsonb_typeof(award.record::jsonb->'subject') = 'string'", 1, true)) +assert(sql:find("response.record::jsonb->'badgeAward'->>'uri' = award.uri", 1, true)) +assert(sql:find("response.record::jsonb->'badgeAward'->>'cid' = award.cid", 1, true)) assert(sql:find("COALESCE(recipient_response.record::jsonb->>'response', 'unanswered') IN", 1, true)) local responseFilter = sql:find("response.record::jsonb->>'response' IN ('accepted', 'rejected')", 1, true) local responseOrder = sql:find('ORDER BY response.indexed_at DESC NULLS LAST, response.uri DESC', 1, true) @@ -352,7 +407,7 @@ local function has(values, expected) for _, value in ipairs(values) do if value == expected then return true end end return false end -for _, value in ipairs({ '${issuer}', '${secondIssuer}', '${badgeUri}', 'certification', '${recipient}', '${subjectRecord}', 'accepted', 'unanswered' }) do +for _, value in ipairs({ '${issuer}', '${secondIssuer}', '${badgeUri}', 'certification', '${recipient}', '${subjectRecord}', 'accepted', 'rejected', 'unanswered' }) do assert(has(calls[1].values, value), 'filter value is bound: ' .. value) end `, From dda4eb1ea39f765e6e547d70e2d2e24fcb8dce47 Mon Sep 17 00:00:00 2001 From: kzoeps Date: Wed, 7 Oct 2026 13:51:51 +0600 Subject: [PATCH 2/2] badges: use absent author in unmatched-filter HTTP test --- api/tests/http/badge-queries.http.test.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api/tests/http/badge-queries.http.test.js b/api/tests/http/badge-queries.http.test.js index 3efd1f5..9cecb76 100644 --- a/api/tests/http/badge-queries.http.test.js +++ b/api/tests/http/badge-queries.http.test.js @@ -291,7 +291,7 @@ test('listBadgeAwards combines discriminating filters and traverses ties in both assert.deepEqual(supersededAcceptedRecipient.body.badgeAwards.map(({ uri }) => uri), [canonicalRecords.awardA.uri]); const unmatchedFilters = [ - { authors: [badgeQueryDids.partner] }, + { authors: [badgeQueryDids.unlistedAuthor] }, { badgeUris: [partnerDefinitionUri] }, { badgeTypes: ['recognition'] }, { subjects: ['did:web:no-such-badge-recipient.example'] },