tinyrelayfile
tinyrelay/scripts/social-compose.test.mjs/

scripts/social-compose.test.mjs raw
1import test from "node:test";
2import assert from "node:assert/strict";
3import fs from "node:fs";
4import vm from "node:vm";
5import {webcrypto,createHash} from "node:crypto";
6
7const source=fs.readFileSync("tinyclient/components.js","utf8");
8const code=source.slice(source.indexOf(" // Social follows"),source.indexOf(" // Social end."));
9const author="a".repeat(64),parentAuthor="b".repeat(64),root="c".repeat(64),parent="d".repeat(64),hint="wss://relay.test";
10function setup(attrs={},values={},options={}) {
11 const attributes={actor:author,relay:hint,...attrs},store=options.store||new Map(),calls=[],signed=[];
12 let now=1789000000,fail=options.fail||0;
13 class FormElement {getAttribute(n){return attributes[n]??null;}report(message){this.message=message;}}
14 class HTMLElement {}
15 const fields=Object.fromEntries(Object.entries(values).map(([name,value])=>[name, typeof value === "object" ? value : {value}]));
16 const form={elements:fields,reset(){for(const field of Object.values(fields))if("value"in field)field.value="";}};
17 const tiny={root:"",localPath:path=>path,sha256hex:async data=>createHash("sha256").update(data).digest("hex"),
18 signing:{signEvent:async unsigned=>{signed.push(unsigned);return {...unsigned,id:"e".repeat(64),pubkey:author};}},
19 signedFetch:async(path,method,body)=>{calls.push({path,method,body});if(path === "/social/preview") return {ok:true,json:async()=>({html:"<p>Preview</p>"})};if(path.startsWith("/upload")){return {ok:true,json:async()=>({sha256:await tiny.sha256hex(body),size:body.length,url:"https://relay.test/file.jpg"})};}if(fail-->0)return {ok:false,json:async()=>({error:"interrupted"})};return {ok:true,json:async()=>({accepted:true})};},
20 navigate:async href=>{tiny.href=href;}
21 };
22 const ctx={FormElement,HTMLElement,tiny,URL,Uint8Array,WeakMap,crypto:webcrypto,location:{origin:"https://relay.test",pathname:"/social",href:"https://relay.test/social"},localStorage:{getItem:k=>store.get(k)||null,setItem:(k,v)=>store.set(k,v),removeItem:k=>store.delete(k)},isHex64:v=>typeof v==="string"&&/^[0-9a-f]{64}$/.test(v),unixNow:()=>now,el:()=>({setAttribute(){}}),fetch:options.fetch};
23 const classes=vm.runInNewContext(code+"\n({SocialCompose,SocialReaction,socialDraftKey})",ctx);
24 const component=new classes.SocialCompose();component.form=form;
25 return {component,form,calls,signed,tiny,store,attrs:attributes,classes,advance(){now+=90;},tags(){return JSON.parse(JSON.stringify(signed.at(-1).tags));}};
26}
27
28test("notes keep plaintext and topics normalized",async()=>{
29 const s=setup({mode:"note"},{content:"**literal**\nTwo lines",tags:"#Nostr, nostr, NEWS"});
30 await s.component.submit(s.form);
31 assert.equal(s.signed[0].kind,1);assert.equal(s.signed[0].content,"**literal**\nTwo lines");
32 assert.deepEqual(s.tags(),[["t","nostr"],["t","news"]]);
33});
34
35test("new articles with identical titles have distinct persistent addresses",async()=>{
36 const s=setup({mode:"article"},{title:"Same title",content:"# Markdown",summary:"Subtitle",cover:"https://cdn.test/a.png"});
37 const other=setup({mode:"article"},{title:"Same title",content:"Another story"});
38 await s.component.submit(s.form);await other.component.submit(other.form);
39 const tags=s.tags();assert.notEqual(tags.find(t=>t[0]==="d")[1],other.tags().find(t=>t[0]==="d")[1]);
40 assert.equal(s.signed[0].kind,30023);assert.ok(tags.some(t=>t[0]==="title"&&t[1]==="Same title"));assert.ok(tags.some(t=>t[0]==="published_at"));
41});
42
43test("NIP-10 top-level reply uses one root marker and inherited p tags",async()=>{
44 const s=setup({mode:"comment",target:root,"target-kind":"1","target-pubkey":author,"target-tags":JSON.stringify([["p",parentAuthor]])},{content:"Reply"});
45 await s.component.submit(s.form);
46 assert.deepEqual(s.tags(),[["e",root,hint,"root",author],["p",author,hint],["p",parentAuthor,hint]]);
47});
48
49test("NIP-10 nested replies distinguish root and direct parent",async()=>{
50 const s=setup({mode:"comment",target:parent,"target-kind":"1","target-pubkey":parentAuthor,root,"root-pubkey":author},{content:"Nested"});
51 await s.component.submit(s.form);assert.deepEqual(s.tags(),[["e",root,hint,"root",author],["e",parent,hint,"reply",parentAuthor],["p",author,hint],["p",parentAuthor,hint]]);
52});
53
54test("NIP-22 article comment has address scope and address plus id parent",async()=>{
55 const address=`30023:${author}:essay:part-one`;
56 const s=setup({mode:"comment",target:root,"target-kind":"30023","target-pubkey":author,address},{content:"Article comment"});
57 await s.component.submit(s.form);assert.equal(s.signed[0].kind,1111);
58 assert.deepEqual(s.tags(),[["A",address,hint],["K","30023"],["P",author,hint],["a",address,hint],["e",root,hint,author],["k","30023"],["p",author,hint]]);
59});
60
61test("NIP-22 nested comment keeps root scope but direct parent's author and kind",async()=>{
62 const address=`30023:${author}:essay`;
63 const s=setup({mode:"comment",target:parent,"target-kind":"1111","target-pubkey":parentAuthor,root,"root-pubkey":author,address},{content:"Nested article comment"});
64 await s.component.submit(s.form);assert.equal(s.signed[0].kind,1111);
65 assert.deepEqual(s.tags(),[["A",address,hint],["K","30023"],["P",author,hint],["e",parent,hint,parentAuthor],["k","1111"],["p",parentAuthor,hint]]);
66});
67
68test("relay failure retains signed event across a later retry",async()=>{
69 const s=setup({mode:"note"},{content:"Retry"},{fail:1});
70 await assert.rejects(s.component.submit(s.form),/interrupted/);s.advance();await s.component.submit(s.form);
71 assert.equal(s.signed.length,1);assert.equal(s.calls.length,2);assert.equal(s.calls[0].body,s.calls[1].body);
72});
73
74test("editing a failed post requires a fresh signature",async()=>{
75 const s=setup({mode:"note"},{content:"Before"},{fail:1});
76 await assert.rejects(s.component.submit(s.form));s.form.elements.content.value="After";await s.component.submit(s.form);assert.equal(s.signed.length,2);
77});
78
79test("duplicate submits publish only once",async()=>{
80 const s=setup({mode:"note"},{content:"One"});await Promise.all([s.component.submit(s.form),s.component.submit(s.form)]);assert.equal(s.signed.length,1);assert.equal(s.calls.length,1);
81});
82
83test("drafts remain scoped to account and article and clear when erased",()=>{
84 const s=setup({mode:"article"},{title:"Story",content:"private draft"});s.component.saveDraft();
85 const other=setup({mode:"article",actor:parentAuthor},{title:"",content:""},{store:s.store});other.component.restoreDraft();assert.equal(other.form.elements.content.value,"");
86 const same=setup({mode:"article"},{title:"",content:""},{store:s.store});same.component.restoreDraft();assert.equal(same.form.elements.content.value,"private draft");
87 s.form.elements.title.value="";s.form.elements.content.value="";s.component.saveDraft();assert.equal(s.store.size,0);
88});
89
90test("media is uploaded once on retry and notes embed raw URLs with imeta",async()=>{
91 const file={name:"photo.jpg",type:"image/jpeg",size:3,arrayBuffer:async()=>new Uint8Array([1,2,3]).buffer};
92 const s=setup({mode:"note"},{content:"Photo",alt:"A mountain", "media-file":{files:[file]}},{fail:1});
93 await assert.rejects(s.component.submit(s.form),/interrupted/);await s.component.submit(s.form);
94 assert.equal(s.calls.filter(c=>c.path.startsWith("/upload")).length,1);
95 assert.equal(s.signed[0].content,"Photo\n\nhttps://relay.test/file.jpg");
96 const imeta=s.tags().find(t=>t[0]==="imeta");assert.ok(imeta.includes("m image/jpeg"));assert.ok(imeta.includes("alt A mountain"));assert.ok(imeta.includes("size 3"));
97});
98
99test("unsafe media and cover URLs are rejected before publishing",async()=>{
100 for(const values of [{content:"note",media:"javascript:alert(1)"},{content:"note",media:"https://secret@example.com/a"}]){const s=setup({mode:"note"},values);await assert.rejects(s.component.submit(s.form));assert.equal(s.signed.length,0);}
101 const s=setup({mode:"article"},{title:"Title",content:"Body",cover:"data:image/svg+xml,bad"});await assert.rejects(s.component.submit(s.form));assert.equal(s.signed.length,0);
102});
103
104
105test("draft previews use authenticated requests without publishing", async()=>{
106 const s=setup({mode:"article"},{content:"**Preview**"});
107 const output={hidden:true,innerHTML:""};s.component.querySelector=()=>output;
108 await s.component.preview();
109 assert.equal(s.calls[0].path,"/social/preview");assert.equal(s.calls[0].method,"POST");
110 assert.deepEqual(JSON.parse(s.calls[0].body),{content:"**Preview**",kind:30023});
111 assert.equal(s.signed.length,0);assert.equal(output.hidden,false);assert.equal(output.innerHTML,"<p>Preview</p>");
112});
113
114test("legacy replies omit an unknown optional root author rather than misattribute it", async()=>{
115 const s=setup({mode:"comment",target:parent,"target-kind":"1","target-pubkey":parentAuthor,root,"root-pubkey":""},{content:"Legacy reply"});
116 await s.component.submit(s.form);
117 assert.deepEqual(s.tags(),[["e",root,hint,"root"],["e",parent,hint,"reply",parentAuthor],["p",parentAuthor,hint]]);
118});

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs