diff --git a/cmd/validate-lexicon/main.go b/cmd/validate-lexicon/main.go index 66b9328..3791d88 100644 --- a/cmd/validate-lexicon/main.go +++ b/cmd/validate-lexicon/main.go @@ -216,6 +216,9 @@ func extractAllSchemaIDs(schemaPath string) []string { // Only include record schemas (not procedures) if strings.Contains(schemaID, ".record") || + strings.Contains(schemaID, ".postv2") || + strings.Contains(schemaID, ".acceptance") || + strings.Contains(schemaID, ".removal") || strings.Contains(schemaID, ".profile") || strings.Contains(schemaID, ".rules") || strings.Contains(schemaID, ".wiki") || diff --git a/internal/atproto/lexicon/social/coves/community/acceptance.json b/internal/atproto/lexicon/social/coves/community/acceptance.json new file mode 100644 index 0000000..5f25c5d --- /dev/null +++ b/internal/atproto/lexicon/social/coves/community/acceptance.json @@ -0,0 +1,27 @@ +{ + "lexicon": 1, + "id": "social.coves.community.acceptance", + "defs": { + "main": { + "type": "record", + "description": "A community's attestation that it accepts a post. Written only by the community's key holder, automatically once admission checks pass - machine attestation, not human approval. The community is implicit in the repository this record lives in. The record key is deterministic: the subject post's AT-URI with \"at://\" stripped and \"/\" replaced by \":\", so a post has exactly one acceptance per community and concurrent writers converge instead of allocating duplicates.", + "key": "any", + "record": { + "type": "object", + "required": ["subject", "createdAt"], + "properties": { + "subject": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the accepted post, pinning the exact accepted version. If the author edits the post the CID no longer matches and the post is pending re-acceptance; clients and AppViews MUST NOT auto-render the new CID under the old acceptance." + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp when the post was accepted" + } + } + } + } + } +} diff --git a/internal/atproto/lexicon/social/coves/community/post.json b/internal/atproto/lexicon/social/coves/community/post.json index 403d15c..d2e81e6 100644 --- a/internal/atproto/lexicon/social/coves/community/post.json +++ b/internal/atproto/lexicon/social/coves/community/post.json @@ -4,7 +4,7 @@ "defs": { "main": { "type": "record", - "description": "A post in a Coves community. Posts live in community repositories and persist independently of the author.", + "description": "A post in a Coves community. Posts live in community repositories and persist independently of the author. DEPRECATED: no new records are written to this collection. Successor: social.coves.community.postv2, which lives in the author's repository.", "key": "tid", "record": { "type": "object", diff --git a/internal/atproto/lexicon/social/coves/community/postv2.json b/internal/atproto/lexicon/social/coves/community/postv2.json new file mode 100644 index 0000000..9f3be26 --- /dev/null +++ b/internal/atproto/lexicon/social/coves/community/postv2.json @@ -0,0 +1,123 @@ +{ + "lexicon": 1, + "id": "social.coves.community.postv2", + "defs": { + "main": { + "type": "record", + "description": "A post authored by a user, living in the author's repository. Successor to the deprecated social.coves.community.post: authorship is derived from the repository the record lives in, so there is no in-record author field. A post claiming a community is not visible in it until the community writes a social.coves.community.acceptance for it - community surfaces render from acceptance records only.", + "key": "tid", + "record": { + "type": "object", + "required": ["community", "createdAt"], + "properties": { + "community": { + "type": "string", + "format": "did", + "description": "DID of the community this was submitted to. Immutable across updates: consumers MUST ignore an update event that changes it, since retargeting a post means writing a new post record." + }, + "title": { + "type": "string", + "maxGraphemes": 300, + "maxLength": 3000, + "description": "Post title (optional for media-only posts)" + }, + "content": { + "type": "string", + "maxGraphemes": 10000, + "maxLength": 100000, + "description": "Post content - supports rich text via facets" + }, + "facets": { + "type": "array", + "description": "Annotations for rich text (mentions, links, formatting, block structure)", + "maxLength": 200, + "items": { + "type": "ref", + "ref": "social.coves.richtext.facet" + } + }, + "embed": { + "type": "union", + "description": "Embedded media, external links, or quoted posts", + "refs": [ + "social.coves.embed.images", + "social.coves.embed.video", + "social.coves.embed.external", + "social.coves.embed.post" + ] + }, + "langs": { + "type": "array", + "description": "Languages used in the post content (ISO 639-1)", + "maxLength": 3, + "items": { + "type": "string", + "format": "language" + } + }, + "labels": { + "type": "ref", + "ref": "com.atproto.label.defs#selfLabels", + "description": "Self-applied content labels (NSFW, spoilers, etc.)" + }, + "tags": { + "type": "array", + "description": "User-applied topic tags", + "maxLength": 8, + "items": { + "type": "string", + "maxGraphemes": 64, + "maxLength": 640 + } + }, + "crosspostOf": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "If this is a crosspost, strong reference to the immediate parent post" + }, + "crosspostChain": { + "type": "array", + "description": "Full chain of crossposts with version pinning. First element is original, last is immediate parent.", + "maxLength": 25, + "items": { + "type": "ref", + "ref": "com.atproto.repo.strongRef" + } + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp of post creation" + }, + "bridgedStats": { + "type": "ref", + "ref": "#bridgedStats", + "description": "Bridge-asserted aggregate of origin-platform votes for federated/bridged content. Set by the bridge that materialized this record; absent for natively-authored posts." + } + } + } + }, + "bridgedStats": { + "type": "object", + "description": "Aggregate vote counts asserted by the bridge for content federated from an origin platform (e.g. Lemmy). These supplement, and are kept separate from, native atproto votes.", + "required": ["upvotes", "downvotes", "asOf"], + "properties": { + "upvotes": { + "type": "integer", + "minimum": 0, + "description": "Number of upvotes on the origin platform as of asOf" + }, + "downvotes": { + "type": "integer", + "minimum": 0, + "description": "Number of downvotes on the origin platform as of asOf" + }, + "asOf": { + "type": "string", + "format": "datetime", + "description": "Timestamp the origin-platform counts were sampled; used to discard stale updates" + } + } + } + } +} diff --git a/internal/atproto/lexicon/social/coves/community/removal.json b/internal/atproto/lexicon/social/coves/community/removal.json new file mode 100644 index 0000000..c99a48b --- /dev/null +++ b/internal/atproto/lexicon/social/coves/community/removal.json @@ -0,0 +1,46 @@ +{ + "lexicon": 1, + "id": "social.coves.community.removal", + "defs": { + "main": { + "type": "record", + "description": "A community's record that a post has been removed from it. Written in the same com.atproto.repo.applyWrites commit that deletes the social.coves.community.acceptance, so the firehose never carries a half-completed moderation action. Removal is URI-scoped and terminal: it applies to the post URI across later author edits, not only to the version it pins. A post rejected at submission time was never accepted and writes no record. The record key is deterministic: the subject post's AT-URI with \"at://\" stripped and \"/\" replaced by \":\".", + "key": "any", + "record": { + "type": "object", + "required": ["subject", "code", "createdAt"], + "properties": { + "subject": { + "type": "ref", + "ref": "com.atproto.repo.strongRef", + "description": "Strong reference to the removed post. The pinned CID is audit metadata recording the version present at removal time; the removal itself applies to the post URI." + }, + "code": { + "type": "string", + "knownValues": [ + "rule-violation", + "spam", + "off-topic", + "illegal-content", + "author-banned", + "moderator-discretion" + ], + "description": "Machine-readable reason for the removal. knownValues is an open set: federated peers may send codes this AppView has not seen yet and their records must still validate, so this MUST NOT become an enum.", + "maxLength": 64 + }, + "reason": { + "type": "string", + "maxGraphemes": 1000, + "maxLength": 10000, + "description": "Human-readable explanation of the removal, shown to the author and in the moderation log" + }, + "createdAt": { + "type": "string", + "format": "datetime", + "description": "Timestamp when the post was removed" + } + } + } + } + } +} diff --git a/tests/lexicon-test-data/acceptance/acceptance-invalid-missing-subject.json b/tests/lexicon-test-data/acceptance/acceptance-invalid-missing-subject.json new file mode 100644 index 0000000..3b7d41d --- /dev/null +++ b/tests/lexicon-test-data/acceptance/acceptance-invalid-missing-subject.json @@ -0,0 +1,4 @@ +{ + "$type": "social.coves.community.acceptance", + "createdAt": "2026-07-22T10:05:00Z" +} diff --git a/tests/lexicon-test-data/acceptance/acceptance-valid-extra-unknown-field.json b/tests/lexicon-test-data/acceptance/acceptance-valid-extra-unknown-field.json new file mode 100644 index 0000000..187a5aa --- /dev/null +++ b/tests/lexicon-test-data/acceptance/acceptance-valid-extra-unknown-field.json @@ -0,0 +1,11 @@ +{ + "$type": "social.coves.community.acceptance", + "subject": { + "uri": "at://did:plc:programming123/social.coves.community.postv2/3k7a3dmb5bk2c", + "cid": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm" + }, + "createdAt": "2026-07-22T10:05:00Z", + "futureExtension": { + "note": "open schema" + } +} diff --git a/tests/lexicon-test-data/acceptance/acceptance-valid.json b/tests/lexicon-test-data/acceptance/acceptance-valid.json new file mode 100644 index 0000000..1662b69 --- /dev/null +++ b/tests/lexicon-test-data/acceptance/acceptance-valid.json @@ -0,0 +1,8 @@ +{ + "$type": "social.coves.community.acceptance", + "subject": { + "uri": "at://did:plc:programming123/social.coves.community.postv2/3k7a3dmb5bk2c", + "cid": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm" + }, + "createdAt": "2026-07-22T10:05:00Z" +} diff --git a/tests/lexicon-test-data/postv2/postv2-invalid-missing-community.json b/tests/lexicon-test-data/postv2/postv2-invalid-missing-community.json new file mode 100644 index 0000000..907245f --- /dev/null +++ b/tests/lexicon-test-data/postv2/postv2-invalid-missing-community.json @@ -0,0 +1,8 @@ +{ + "$type": "social.coves.community.postv2", + "title": "Test Post", + "content": "This post is missing the required community field", + "tags": ["test"], + "langs": ["en"], + "createdAt": "2025-01-09T14:30:00Z" +} diff --git a/tests/lexicon-test-data/postv2/postv2-invalid-missing-createdat.json b/tests/lexicon-test-data/postv2/postv2-invalid-missing-createdat.json new file mode 100644 index 0000000..e8e3d0e --- /dev/null +++ b/tests/lexicon-test-data/postv2/postv2-invalid-missing-createdat.json @@ -0,0 +1,8 @@ +{ + "$type": "social.coves.community.postv2", + "community": "did:plc:programming123", + "title": "Test Post", + "content": "This post is missing the required createdAt field", + "tags": ["test"], + "langs": ["en"] +} diff --git a/tests/lexicon-test-data/postv2/postv2-valid-full.json b/tests/lexicon-test-data/postv2/postv2-valid-full.json new file mode 100644 index 0000000..a3fecf5 --- /dev/null +++ b/tests/lexicon-test-data/postv2/postv2-valid-full.json @@ -0,0 +1,45 @@ +{ + "$type": "social.coves.community.postv2", + "community": "did:plc:programming123", + "title": "Bridged megathread: the full optional surface", + "content": "Every optional field on postv2 is populated here so a ref that stops resolving fails this fixture.", + "embed": { + "$type": "social.coves.embed.external", + "external": { + "uri": "https://example.com/articles/error-handling", + "title": "Error handling in Go", + "description": "A long-form article about wrapping errors.", + "domain": "example.com", + "embedType": "article" + } + }, + "crosspostOf": { + "uri": "at://did:plc:programming123/social.coves.community.postv2/3k7a3dmb5bk2c", + "cid": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm" + }, + "crosspostChain": [ + { + "uri": "at://did:plc:originalcommunity1/social.coves.community.postv2/3k7a3dmb5bk2a", + "cid": "bafyreigbtj4x7ip5legnfznufuopl4sg4knzc2cof6duas4b3q2fy6swua" + }, + { + "uri": "at://did:plc:programming123/social.coves.community.postv2/3k7a3dmb5bk2c", + "cid": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm" + } + ], + "labels": { + "values": [ + { + "val": "spoiler" + } + ] + }, + "tags": ["golang", "bridged"], + "langs": ["en"], + "bridgedStats": { + "upvotes": 412, + "downvotes": 17, + "asOf": "2026-07-22T09:55:00Z" + }, + "createdAt": "2026-07-22T10:00:00Z" +} diff --git a/tests/lexicon-test-data/postv2/postv2-valid-richtext-blocks.json b/tests/lexicon-test-data/postv2/postv2-valid-richtext-blocks.json new file mode 100644 index 0000000..ba3d507 --- /dev/null +++ b/tests/lexicon-test-data/postv2/postv2-valid-richtext-blocks.json @@ -0,0 +1,58 @@ +{ + "$type": "social.coves.community.postv2", + "community": "did:plc:programming123", + "title": "Cross-posted from Lemmy: The Button", + "content": "The Button\nThey said\nDo not press\nUse:\nfmt.Println(\"hi\")", + "facets": [ + { + "index": { + "byteStart": 0, + "byteEnd": 10 + }, + "features": [ + { + "$type": "social.coves.richtext.facet#heading", + "level": 2 + } + ] + }, + { + "index": { + "byteStart": 11, + "byteEnd": 20 + }, + "features": [ + { + "$type": "social.coves.richtext.facet#blockquote", + "level": 1 + } + ] + }, + { + "index": { + "byteStart": 21, + "byteEnd": 33 + }, + "features": [ + { + "$type": "social.coves.richtext.facet#blockquote", + "level": 2 + } + ] + }, + { + "index": { + "byteStart": 39, + "byteEnd": 56 + }, + "features": [ + { + "$type": "social.coves.richtext.facet#codeBlock", + "language": "go" + } + ] + } + ], + "langs": ["en"], + "createdAt": "2026-07-22T10:00:00Z" +} diff --git a/tests/lexicon-test-data/postv2/postv2-valid-text.json b/tests/lexicon-test-data/postv2/postv2-valid-text.json new file mode 100644 index 0000000..851ea67 --- /dev/null +++ b/tests/lexicon-test-data/postv2/postv2-valid-text.json @@ -0,0 +1,22 @@ +{ + "$type": "social.coves.community.postv2", + "community": "did:plc:programming123", + "title": "Best practices for error handling in Go", + "content": "I've been working with Go for a while now and wanted to share some thoughts on error handling patterns...", + "facets": [ + { + "index": { + "byteStart": 20, + "byteEnd": 22 + }, + "features": [ + { + "$type": "social.coves.richtext.facet#bold" + } + ] + } + ], + "tags": ["golang", "error-handling", "best-practices"], + "langs": ["en"], + "createdAt": "2025-01-09T14:30:00Z" +} diff --git a/tests/lexicon-test-data/removal/removal-invalid-missing-code.json b/tests/lexicon-test-data/removal/removal-invalid-missing-code.json new file mode 100644 index 0000000..7e180c3 --- /dev/null +++ b/tests/lexicon-test-data/removal/removal-invalid-missing-code.json @@ -0,0 +1,9 @@ +{ + "$type": "social.coves.community.removal", + "subject": { + "uri": "at://did:plc:programming123/social.coves.community.postv2/3k7a3dmb5bk2c", + "cid": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm" + }, + "reason": "A removal without a machine-readable code cannot be acted on downstream.", + "createdAt": "2026-07-22T10:20:00Z" +} diff --git a/tests/lexicon-test-data/removal/removal-invalid-reason-too-long.json b/tests/lexicon-test-data/removal/removal-invalid-reason-too-long.json new file mode 100644 index 0000000..3b1bccd --- /dev/null +++ b/tests/lexicon-test-data/removal/removal-invalid-reason-too-long.json @@ -0,0 +1,10 @@ +{ + "$type": "social.coves.community.removal", + "subject": { + "uri": "at://did:plc:programming123/social.coves.community.postv2/3k7a3dmb5bk2c", + "cid": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm" + }, + "code": "spam", + "reason": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "createdAt": "2026-07-22T10:25:00Z" +} diff --git a/tests/lexicon-test-data/removal/removal-valid-unknown-code.json b/tests/lexicon-test-data/removal/removal-valid-unknown-code.json new file mode 100644 index 0000000..26665f9 --- /dev/null +++ b/tests/lexicon-test-data/removal/removal-valid-unknown-code.json @@ -0,0 +1,10 @@ +{ + "$type": "social.coves.community.removal", + "subject": { + "uri": "at://did:plc:programming123/social.coves.community.postv2/3k7a3dmb5bk2c", + "cid": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm" + }, + "code": "custom-future-code", + "reason": "A code this AppView has never heard of, sent by a federated peer.", + "createdAt": "2026-07-22T10:15:00Z" +} diff --git a/tests/lexicon-test-data/removal/removal-valid.json b/tests/lexicon-test-data/removal/removal-valid.json new file mode 100644 index 0000000..b8a54ce --- /dev/null +++ b/tests/lexicon-test-data/removal/removal-valid.json @@ -0,0 +1,10 @@ +{ + "$type": "social.coves.community.removal", + "subject": { + "uri": "at://did:plc:programming123/social.coves.community.postv2/3k7a3dmb5bk2c", + "cid": "bafyreib6tbnql2ux3whnfysbzabthaj2vvck53nimhbi5g5a7jgvgr5eqm" + }, + "code": "spam", + "reason": "Repeated affiliate links across six threads in under an hour.", + "createdAt": "2026-07-22T10:10:00Z" +} diff --git a/tests/lexicon_fixtures_test.go b/tests/lexicon_fixtures_test.go new file mode 100644 index 0000000..afd4253 --- /dev/null +++ b/tests/lexicon_fixtures_test.go @@ -0,0 +1,217 @@ +package tests + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + lexicon "github.com/bluesky-social/indigo/atproto/lexicon" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fixtureDir holds hand-written record samples, one JSON file per case, grouped +// into a subdirectory per record family. cmd/validate-lexicon walks the same +// tree; this test exists so the fixtures are also a merge gate at T0. +const fixtureDir = "lexicon-test-data" + +// invalidFixtureMarker in a fixture's basename declares that the record MUST be +// rejected. Both hyphens are load-bearing: cmd/validate-lexicon matches the +// same substring, so renaming a fixture to "foo-invalid.json" silently flips it +// into a should-pass case in both harnesses. +const invalidFixtureMarker = "-invalid-" + +// loadFixtureCatalog builds the catalog every fixture is validated against. +func loadFixtureCatalog(t *testing.T) *lexicon.BaseCatalog { + t.Helper() + + catalog := lexicon.NewBaseCatalog() + if err := catalog.LoadDirectory(lexiconDir); err != nil { + t.Fatalf("Failed to load lexicon schemas from %s: %v", lexiconDir, err) + } + return &catalog +} + +// decodeFixture parses a fixture the way cmd/validate-lexicon does: numbers are +// decoded with UseNumber and then narrowed to int64 where possible. Plain +// json.Unmarshal yields float64 for every number, which fails validation of +// integer-typed fields — a divergence between the two harnesses would mean a +// fixture passes in one and fails in the other. +func decodeFixture(t *testing.T, path string) map[string]interface{} { + t.Helper() + + raw, err := os.ReadFile(path) + require.NoError(t, err, "reading fixture %s", path) + + var record map[string]interface{} + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.UseNumber() + require.NoError(t, decoder.Decode(&record), "parsing fixture %s", path) + + return narrowNumbers(record).(map[string]interface{}) +} + +// narrowNumbers walks a decoded record and turns every json.Number into the +// concrete Go type indigo's validator expects. +func narrowNumbers(value interface{}) interface{} { + switch typed := value.(type) { + case map[string]interface{}: + converted := make(map[string]interface{}, len(typed)) + for key, member := range typed { + converted[key] = narrowNumbers(member) + } + return converted + case []interface{}: + converted := make([]interface{}, len(typed)) + for i, member := range typed { + converted[i] = narrowNumbers(member) + } + return converted + case json.Number: + if asInt, err := typed.Int64(); err == nil { + return asInt + } + if asFloat, err := typed.Float64(); err == nil { + return asFloat + } + return typed.String() + default: + return value + } +} + +// collectFixturePaths returns every fixture JSON under fixtureDir, keyed by its +// path relative to fixtureDir so subtest names read as "postv2/postv2-valid-text.json". +func collectFixturePaths(t *testing.T) []string { + t.Helper() + + var paths []string + err := filepath.Walk(fixtureDir, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || !strings.HasSuffix(path, ".json") { + return nil + } + relPath, err := filepath.Rel(fixtureDir, path) + if err != nil { + return err + } + paths = append(paths, relPath) + return nil + }) + if err != nil { + t.Fatalf("Failed to walk %s: %v", fixtureDir, err) + } + // A moved or renamed fixture directory must not pass vacuously. + if len(paths) == 0 { + t.Fatalf("No fixture files found under %s", fixtureDir) + } + return paths +} + +// TestLexiconFixtures validates every record fixture against the published +// schemas. The basename decides the expectation: "-invalid-" means the record +// must be rejected, anything else means it must be accepted. +func TestLexiconFixtures(t *testing.T) { + catalog := loadFixtureCatalog(t) + + for _, relPath := range collectFixturePaths(t) { + t.Run(filepath.ToSlash(relPath), func(t *testing.T) { + record := decodeFixture(t, filepath.Join(fixtureDir, relPath)) + + recordType, ok := record["$type"].(string) + require.True(t, ok, "fixture %s has no top-level string $type", relPath) + + // AllowLenientDatetime matches cmd/validate-lexicon's default mode. + err := lexicon.ValidateRecord(catalog, record, recordType, lexicon.AllowLenientDatetime) + + if strings.Contains(filepath.Base(relPath), invalidFixtureMarker) { + assert.Error(t, err, "expected validation failure but record validated as %s", recordType) + return + } + assert.NoError(t, err, "expected %s to validate as %s", relPath, recordType) + }) + } +} + +// resolveRecordSchema resolves an NSID and asserts it is a record schema. +func resolveRecordSchema(t *testing.T, catalog *lexicon.BaseCatalog, nsid string) lexicon.SchemaRecord { + t.Helper() + + schema, err := catalog.Resolve(nsid) + require.NoError(t, err, "resolving %s", nsid) + + record, ok := schema.Def.(lexicon.SchemaRecord) + require.True(t, ok, "%s is not a record schema (got %T)", nsid, schema.Def) + return record +} + +// TestLexiconRecordShapes pins schema shape rather than record content. +// +// atproto lexicons are OPEN: an unknown field on a record validates fine. That +// makes field ABSENCE unobservable from data fixtures — no fixture can prove +// that postv2 dropped "author", because a fixture carrying "author" would +// validate either way. The same goes for a knownValues set being tightened into +// a closed enum: every existing fixture keeps passing, and only records using a +// new value start failing, in production. These assertions read the schema +// directly so those regressions fail here instead. +func TestLexiconRecordShapes(t *testing.T) { + catalog := loadFixtureCatalog(t) + + t.Run("social.coves.community.postv2", func(t *testing.T) { + record := resolveRecordSchema(t, catalog, "social.coves.community.postv2") + + assert.Equal(t, "tid", record.Key, "postv2 records are keyed by TID") + + // postv2 exists to drop the author field: authorship comes from the + // repo the record lives in, not from a self-asserted DID. + assert.NotContains(t, record.Record.Properties, "author", + "postv2 must not carry a self-asserted author DID") + + assert.ElementsMatch(t, []string{"community", "createdAt"}, record.Record.Required, + "postv2 required fields") + }) + + t.Run("social.coves.community.acceptance", func(t *testing.T) { + record := resolveRecordSchema(t, catalog, "social.coves.community.acceptance") + + assert.Equal(t, "any", record.Key, "an acceptance is keyed by the subject it accepts") + assert.ElementsMatch(t, []string{"subject", "createdAt"}, record.Record.Required, + "acceptance required fields") + + subject, ok := record.Record.Properties["subject"] + require.True(t, ok, "acceptance has no subject property") + subjectRef, ok := subject.Inner.(lexicon.SchemaRef) + require.True(t, ok, "acceptance subject is not a ref (got %T)", subject.Inner) + assert.Equal(t, "com.atproto.repo.strongRef", subjectRef.Ref, + "acceptance must pin the exact version of what it accepts") + }) + + t.Run("social.coves.community.removal", func(t *testing.T) { + record := resolveRecordSchema(t, catalog, "social.coves.community.removal") + + assert.Equal(t, "any", record.Key, "a removal is keyed by the subject it removes") + assert.ElementsMatch(t, []string{"subject", "code", "createdAt"}, record.Record.Required, + "removal required fields") + + code, ok := record.Record.Properties["code"] + require.True(t, ok, "removal has no code property") + codeString, ok := code.Inner.(lexicon.SchemaString) + require.True(t, ok, "removal code is not a string (got %T)", code.Inner) + + require.NotNil(t, codeString.MaxLength, "removal code must bound its length") + assert.Equal(t, 64, *codeString.MaxLength, "removal code maxLength") + + // knownValues, never enum: federated peers must be able to send removal + // codes this AppView has not heard of yet without their records being + // rejected outright. + assert.Empty(t, codeString.Enum, + "removal code must stay an open knownValues set, not a closed enum") + assert.Contains(t, codeString.KnownValues, "spam", + "removal code knownValues must document the common codes") + }) +}