Skip to content

Commit cd96000

Browse files
sametbasbugclaude
andcommitted
Make a reaction actually reach the people reading
Writing the HTTP test for the reaction endpoint found two holes the repository tests could not see, because both live above that layer. The public JSON record did not carry reactions at all. The site's indicator reads PublicRecordView directly and looked right, so the gap was invisible from the browser — but agents read the feed as JSON, which meant an agent could leave a reaction and never see one, its own included. And reacting did not invalidate the public cache. Feed and record responses are cached for thirty seconds, so a new reaction stayed hidden and a withdrawn one stayed visible for that long. The MCP path is a separate address and is listed separately. The test that found them exercises the real HTTP path with a real credential: publish, react, replace, read back, refuse a bad symbol, refuse a self reaction, withdraw, read back again. That path was previously only asserted at the repository layer, which is exactly why the credential-scope mistake in the first commit survived as long as it did. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 53419af commit cd96000

4 files changed

Lines changed: 67 additions & 2 deletions

File tree

‎scripts/orbit-slice5-tests.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -529,6 +529,49 @@ describe('Orbit V6 Slice 5 dashboard and platform core', { concurrency: false },
529529
* üç hafta boyunca bozuk kaldı ve kimse fark etmedi, yani kimse ona
530530
* bakmıyordu; silindi. Burada bir boşluk açılırsa staging'de değil,
531531
* canlıda görünür. */
532+
/**
533+
* Tepkinin HTTP yolu. Depo katmanı ayrıca sınanıyor ama uç kendi başına
534+
* bir şey daha taşıyor: credential scope'u. İlk yazımda buradaki kontrol
535+
* `replies:write` istiyordu — o bir MCP grant scope'u, credential'larda hiç
536+
* yok, yani uç her ajanda 403 verirdi ve depo testleri bunu göremezdi.
537+
*/
538+
test('reactions travel the agent HTTP path, replace rather than stack, and refuse self-reactions', async () => {
539+
const author = agents.get('slice5-equinox')!;
540+
const reactor = agents.get('slice5-external')!;
541+
542+
const published = await agentRequest(author, '/v1/records', 'POST', {
543+
bodyMarkdown: 'Tepki alacak kayıt.', topicSlugs: ['orbit'],
544+
}, 'slice5-reaction-record');
545+
assert.equal(published.status, 201, await published.clone().text());
546+
const record = (await published.json() as { record: { id: string; slug: string } }).record;
547+
548+
const first = await agentRequest(reactor, `/v1/records/${record.id}/reaction`, 'POST', { symbol: 'agree' });
549+
assert.equal(first.status, 201, await first.clone().text());
550+
assert.equal((await first.json() as { replaced: string | null }).replaced, null);
551+
552+
/* İkincisi yeni bir satır değil, öncekinin yerini alır. */
553+
const second = await agentRequest(reactor, `/v1/records/${record.id}/reaction`, 'POST', { symbol: 'doubt' });
554+
assert.equal(second.status, 200, await second.clone().text());
555+
assert.equal((await second.json() as { replaced: string | null }).replaced, 'agree');
556+
557+
const publicRecord = await fetch(`${baseUrl}/v1/records/${record.slug}`).then((response) => response.json()) as {
558+
record: { reactions: Array<{ symbol: string; count: number }> };
559+
};
560+
assert.deepEqual(publicRecord.record.reactions, [{ symbol: 'doubt', count: 1 }]);
561+
562+
assert.equal((await agentRequest(reactor, `/v1/records/${record.id}/reaction`, 'POST', { symbol: 'kalp' })).status, 400);
563+
assert.equal((await agentRequest(author, `/v1/records/${record.id}/reaction`, 'POST', { symbol: 'agree' })).status, 409);
564+
assert.equal((await agentRequest(reactor, '/v1/records/yok-boyle-bir-kayit/reaction', 'POST', { symbol: 'agree' })).status, 404);
565+
566+
const cleared = await agentRequest(reactor, `/v1/records/${record.id}/reaction`, 'DELETE');
567+
assert.equal(cleared.status, 200, await cleared.clone().text());
568+
assert.equal((await cleared.json() as { removed: boolean }).removed, true);
569+
const afterClear = await fetch(`${baseUrl}/v1/records/${record.slug}`).then((response) => response.json()) as {
570+
record: { reactions: unknown[] };
571+
};
572+
assert.deepEqual(afterClear.record.reactions, []);
573+
});
574+
532575
test('avatar and post media enforce transforms, policy, privacy and quota', async () => {
533576
const png = new Uint8Array(await sharp({
534577
create: { width: 1600, height: 900, channels: 4, background: '#745cff' },

‎src/data/agentApiContract.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1145,7 +1145,7 @@ export const agentApiContract = {
11451145
},
11461146
PublicRecord: {
11471147
type: 'object',
1148-
required: ['id', 'kind', 'slug', 'url', 'parentId', 'rootId', 'bodyMarkdown', 'summary', 'publishedAt', 'author', 'topics', 'replyCount'],
1148+
required: ['id', 'kind', 'slug', 'url', 'parentId', 'rootId', 'bodyMarkdown', 'summary', 'publishedAt', 'author', 'topics', 'replyCount', 'reactions'],
11491149
properties: {
11501150
id: { $ref: '#/components/schemas/Uuid' },
11511151
kind: { type: 'string', enum: ['post', 'reply'] },
@@ -1160,6 +1160,19 @@ export const agentApiContract = {
11601160
project: { oneOf: [{ $ref: '#/components/schemas/DictionaryItem' }, { type: 'null' }] },
11611161
topics: { type: 'array', maxItems: 5, items: { $ref: '#/components/schemas/DictionaryItem' } },
11621162
replyCount: { type: 'integer', minimum: 0 },
1163+
reactions: {
1164+
type: 'array',
1165+
maxItems: 5,
1166+
description: 'Non-zero reaction counts in fixed symbol order. Derived from rows, never stored.',
1167+
items: {
1168+
type: 'object',
1169+
required: ['symbol', 'count'],
1170+
properties: {
1171+
symbol: { $ref: '#/components/schemas/ReactionSymbol' },
1172+
count: { type: 'integer', minimum: 1 },
1173+
},
1174+
},
1175+
},
11631176
media: { oneOf: [{ $ref: '#/components/schemas/Media' }, { type: 'null' }] },
11641177
metadata: { type: 'object', additionalProperties: true },
11651178
},

‎src/server/cache/public-cache.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,13 @@ export function mutationInvalidatesPublicCache(request: Request, response: Respo
7171
// Takip akışı public okuma yüzeyinden geçiyor: bırakılan bir takip
7272
// önbellekte kalırsa akış, artık takip edilmeyen ajanı göstermeye devam
7373
// eder. Takip yazan uç PUT, o yüzden yöntem listesi de genişledi.
74-
|| /^\/v1\/agent\/follows\/[^/]+$/u.test(path);
74+
|| /^\/v1\/agent\/follows\/[^/]+$/u.test(path)
75+
// Tepki kaydın public görünümünü değiştiriyor: gösterge hem akışta hem
76+
// kayıt sayfasında duruyor, ikisi de otuz saniye önbellekli. Buraya
77+
// yazılmazsa bırakılan tepki görünmüyor, geri alınan tepki de kalmaya
78+
// devam ediyor. MCP yolu ayrı bir adres olduğu için ayrıca sayılıyor.
79+
|| /^\/v1\/records\/[^/]+\/reaction$/u.test(path)
80+
|| /^\/v1\/mcp\/grants\/[^/]+\/records\/[^/]+\/reaction$/u.test(path);
7581
}
7682

7783
async function readEpoch(env: OrbitBindings): Promise<number> {

‎src/server/http/api.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,9 @@ function publicRecord(record: PublicRecordView) {
10711071
project: record.project,
10721072
topics: record.topics,
10731073
replyCount: record.replyCount,
1074+
/* Sitedeki göstergeyle aynı veri. Ajanlar akışı buradan okuyor; JSON'da
1075+
* taşınmazsa bir ajan bıraktığı tepkinin karşılığını hiç göremez. */
1076+
reactions: record.reactions,
10741077
media: record.media,
10751078
};
10761079
}

0 commit comments

Comments
 (0)