diff --git a/.changeset/refactor-add-contribution-update-hypercert.md b/.changeset/refactor-add-contribution-update-hypercert.md new file mode 100644 index 00000000..76a900d3 --- /dev/null +++ b/.changeset/refactor-add-contribution-update-hypercert.md @@ -0,0 +1,121 @@ +--- +"@hypercerts-org/sdk-core": minor +--- + +Fix `addContribution` to properly update hypercerts with contributor references + +**Breaking Changes:** + +The `addContribution` method signature has changed to align with the `create()` method's contribution handling: + +- `hypercertUri` is now **required** (was optional) +- `contributors` now accepts `Array` (was `string[]`) + - Supports DIDs, StrongRefs, or inline contributor creation params +- `contributionDetails` parameter **replaces** separate `role`/`description` params + - Supports inline role strings, StrongRefs, or inline contribution creation params +- Added optional `weight` parameter for contribution weighting +- Added optional `onProgress` callback for progress tracking +- Returns `UpdateResult` instead of `CreateResult` (since it updates the hypercert) + +**What Changed:** + +The method now correctly: + +- Creates or references contributionDetails records +- Creates or references contributorInformation records +- **Updates the hypercert's `contributors` array** with the new entries (this was the bug) +- Supports batch addition of multiple contributors in one call +- Preserves existing contributors when adding new ones + +**Migration:** + +```typescript +// Before (0.10.0-beta.7 and earlier): +await repo.hypercerts.addContribution({ + hypercertUri: "at://...", // optional + contributors: ["did:plc:user1"], + role: "Developer", + description: "Built features", +}); + +// After (0.10.0-beta.8+): +await repo.hypercerts.addContribution({ + hypercertUri: "at://...", // required + contributors: ["did:plc:user1"], // or StrongRef or create params + contributionDetails: "Developer", // or StrongRef or create params object + weight: "1.0", // optional +}); + +// With detailed contribution record: +await repo.hypercerts.addContribution({ + hypercertUri: "at://...", + contributors: [ + { + identifier: "did:plc:user1", + displayName: "Alice", + image: avatarBlob, + }, + ], + contributionDetails: { + role: "Developer", + contributionDescription: "Built features", + startDate: "2024-01-01", + endDate: "2024-06-30", + }, + weight: "2.0", +}); +``` + +**Additional Breaking Change:** + +The `update()` method signature has been corrected to accept actual record fields: + +- Now accepts `UpdateHypercertParams` (fields from `HypercertClaim` record schema) +- Previously accepted `Partial` (SDK input format) + +**Why this change:** + +- Prevents invalid fields like `contributions` being added to records +- Allows updating `contributors` array directly (which exists in the schema) +- Type-safe - can only update fields that actually exist in the record +- Validation now works correctly + +**Migration for update():** + +Most code should continue to work since common fields like `title`, `description`, `startDate`, etc. exist in both +formats. + +If you were using SDK input fields that don't exist in records (e.g., `contributions`), you'll need to update: + +```typescript +// Before: +await repo.hypercerts.update({ + uri: hypercertUri, + updates: { + contributions: [...], // ❌ Invalid - doesn't exist in record + }, +}); + +// After: Use the actual record field +await repo.hypercerts.update({ + uri: hypercertUri, + updates: { + contributors: [...], // ✅ Valid - exists in record schema + }, +}); + +// Or use the new addContribution method +await repo.hypercerts.addContribution({ + hypercertUri: hypercertUri, + contributors: ["did:plc:user1"], + contributionDetails: "Developer", +}); +``` + +**Implementation Details:** + +- Added `UpdateHypercertParams` type for type-safe record updates +- Added `buildContributorEntries()` helper to resolve and build contributor entries +- Added `attachContributorsToHypercert()` helper to update hypercerts with new contributors +- Refactored `processContributors()` to reuse `buildContributorEntries()` for consistency +- Removed unused `createContributionsWithProgress()` method diff --git a/packages/sdk-core/src/index.ts b/packages/sdk-core/src/index.ts index ee46ba18..cf1ad90d 100644 --- a/packages/sdk-core/src/index.ts +++ b/packages/sdk-core/src/index.ts @@ -95,10 +95,8 @@ export type { LocationParams, ContributionDetailsParams, CreateContributionDetailsParams, - ResolvedContributionDetails, ContributorIdentityParams, CreateContributorInformationParams, - ResolvedContributorIdentity, } from "./repository/interfaces.js"; // ============================================================================ diff --git a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts index 36f1d8cc..f32194e4 100644 --- a/packages/sdk-core/src/repository/HypercertOperationsImpl.ts +++ b/packages/sdk-core/src/repository/HypercertOperationsImpl.ts @@ -31,6 +31,7 @@ import { type HypercertRights, type JsonBlobRef, type OrgHypercertsDefs, + type RefUri, type StrongRef, type UpdateCollectionParams, type UpdateProjectParams, @@ -42,19 +43,19 @@ import type { BlobOperations, LocationParams, CreateHypercertParams, + UpdateHypercertParams, CreateHypercertResult, HypercertEvents, HypercertOperations, ContributionDetailsParams, - ResolvedContributionDetails, ContributorIdentityParams, - ResolvedContributorIdentity, } from "./interfaces.js"; import type { CreateResult, ListParams, PaginatedList, ProgressStep, UpdateResult } from "./types.js"; import { uploadResultToBlobRef } from "./types.js"; import { $Typed } from "@atproto/api"; import { sha256Hash } from "../lib/crypto.js"; import { isValidUri } from "../lib/url-utils.js"; +import { parseAtUri, isValidAtUri, type AtUriComponents } from "../lexicons/utils.js"; /** * Implementation of high-level hypercert operations. @@ -179,6 +180,24 @@ export class HypercertOperationsImpl extends EventEmitter imple } } + /** + * Parse and validate an AT-URI, throwing ValidationError on failure. + * + * Extracts the DID, collection NSID, and record key from an AT-URI string. + * Validates the URI format and throws a descriptive error if invalid. + * + * @param uri - The AT-URI to parse (e.g., "at://did:plc:abc/collection/rkey") + * @returns The parsed URI components (did, collection, rkey) + * @throws {@link ValidationError} If the URI format is invalid + * @internal + */ + private parseAndValidateUri(uri: string): AtUriComponents { + if (!isValidAtUri(uri)) { + throw new ValidationError(`Invalid AT-URI format: ${uri}`); + } + return parseAtUri(uri); + } + /** * Creates a rights record for a hypercert. * @@ -255,9 +274,9 @@ export class HypercertOperationsImpl extends EventEmitter imple locationRefs: Array<{ uri: string; cid: string }> | undefined, contributorsData: | Array<{ - contributorIdentity: ResolvedContributorIdentity; + contributorIdentity: RefUri; contributionWeight?: string; - contributionDetails?: ResolvedContributionDetails; + contributionDetails?: RefUri; }> | undefined, createdAt: string, @@ -404,50 +423,6 @@ export class HypercertOperationsImpl extends EventEmitter imple } } - /** - * Creates contribution records with progress tracking. - * - * @param hypercertUri - URI of the hypercert - * @param contributions - Array of contribution data - * @param onProgress - Optional progress callback - * @returns Promise resolving to array of contribution URIs - * @internal - */ - private async createContributionsWithProgress( - hypercertUri: string, - contributions: Array<{ - contributors: Array; - role: string; - description?: string; - weight?: string; - }>, - onProgress?: (step: ProgressStep) => void, - ): Promise { - this.emitProgress(onProgress, { name: "createContributions", status: "start" }); - try { - const contributionUris: string[] = []; - for (const contrib of contributions) { - const contribResult = await this.addContribution({ - hypercertUri, - contributors: contrib.contributors.filter((c): c is string => typeof c === "string"), - role: contrib.role, - description: contrib.description, - }); - contributionUris.push(contribResult.uri); - } - this.emitProgress(onProgress, { - name: "createContributions", - status: "success", - data: { count: contributionUris.length }, - }); - return contributionUris; - } catch (error) { - this.emitProgress(onProgress, { name: "createContributions", status: "error", error: error as Error }); - this.logger?.warn(`Failed to create contributions: ${error instanceof Error ? error.message : "Unknown"}`); - throw error; - } - } - /** * Creates attachment records and returns their URIs. * @@ -667,17 +642,9 @@ export class HypercertOperationsImpl extends EventEmitter imple * }); * ``` */ - async update(params: { - uri: string; - updates: Partial; - image?: Blob | null; - }): Promise { + async update(params: { uri: string; updates: UpdateHypercertParams; image?: Blob | null }): Promise { try { - const uriMatch = params.uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${params.uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(params.uri); const existing = await this.agent.com.atproto.repo.getRecord({ repo: this.repoDid, @@ -753,11 +720,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async get(uri: string): Promise<{ uri: string; cid: string; record: HypercertClaim }> { try { - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); const result = await this.agent.com.atproto.repo.getRecord({ repo: this.repoDid, @@ -844,11 +807,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async delete(uri: string): Promise { try { - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); const result = await this.agent.com.atproto.repo.deleteRecord({ repo: this.repoDid, @@ -1034,17 +993,12 @@ export class HypercertOperationsImpl extends EventEmitter imple return this.createLocationRecord(location); } - // Otherwise it's string | StrongRef, resolve to StrongRef + // Otherwise it's RefUri, resolve to StrongRef return this.resolveToStrongRef(location); } private async resolveStrongRefFromUri(uri: string): Promise { - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid AT-URI format: "${uri}"`); - } - - const [, repo, collection, rkey] = uriMatch; + const { did: repo, collection, rkey } = this.parseAndValidateUri(uri); const record = await this.agent.com.atproto.repo.getRecord({ repo, collection, rkey }); if (!record.success) { throw new NetworkError(`Failed to fetch record for repo=${repo}, collection=${collection}, rkey=${rkey}`); @@ -1067,7 +1021,7 @@ export class HypercertOperationsImpl extends EventEmitter imple * @throws {@link NetworkError} When getRecord fails * @internal */ - private async resolveToStrongRef(input: string | StrongRef): Promise { + private async resolveToStrongRef(input: RefUri): Promise { // Check if already a StrongRef if (typeof input === "object" && "uri" in input && "cid" in input) { return { @@ -1095,9 +1049,7 @@ export class HypercertOperationsImpl extends EventEmitter imple * @throws {@link NetworkError} if fetching subject record fails * @internal */ - private async resolveAttachmentSubjects( - subjectsInput: string | StrongRef | Array, - ): Promise { + private async resolveAttachmentSubjects(subjectsInput: RefUri | RefUri[]): Promise { const subjectsArray = Array.isArray(subjectsInput) ? subjectsInput : [subjectsInput]; return await Promise.all(subjectsArray.map((subject) => this.resolveToStrongRef(subject))); @@ -1318,44 +1270,72 @@ export class HypercertOperationsImpl extends EventEmitter imple onProgress?: (step: ProgressStep) => void, ): Promise< | Array<{ - contributorIdentity: ResolvedContributorIdentity; + contributorIdentity: RefUri; contributionWeight?: string; - contributionDetails?: ResolvedContributionDetails; + contributionDetails?: RefUri; }> | undefined > { if (!contributions || contributions.length === 0) return undefined; - const contributorsPromises = contributions.map(async (contrib) => { - // Resolve contributionDetails - const detailsRef = await this.resolveContributionDetails(contrib.contributionDetails, onProgress); + const contributorPromises = contributions.map((contrib) => + this.buildContributorEntries(contrib.contributors, contrib.contributionDetails, contrib.weight, onProgress), + ); - // Resolve each contributor identity - const resolvedContributors = await Promise.all( - contrib.contributors.map((identity) => this.resolveContributorIdentity(identity, onProgress)), - ); + const nestedContributors = await Promise.all(contributorPromises); + return nestedContributors.flat(); + } + + /** + * Creates a standalone contributionDetails record. + * @internal + */ + private async createContributionDetailsRecord(params: { + role: string; + contributionDescription?: string; + startDate?: string; + endDate?: string; + [key: string]: unknown; + }): Promise { + const createdAt = new Date().toISOString(); + const { role, contributionDescription, startDate, endDate, ...extraProps } = params; + const contributionRecord: HypercertContributionDetails = { + $type: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, + role, + createdAt, + contributionDescription, + startDate, + endDate, + ...extraProps, + }; + + const validation = validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, "main", false); + if (!validation.success) { + throw new ValidationError(`Invalid contribution details record: ${validation.error?.message}`); + } - // Expand to one entry per contributor - return resolvedContributors.map((identity) => ({ - contributorIdentity: identity, - contributionWeight: contrib.weight, - contributionDetails: detailsRef, - })); + const result = await this.agent.com.atproto.repo.createRecord({ + repo: this.repoDid, + collection: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, + record: contributionRecord as Record, }); - const nestedContributors = await Promise.all(contributorsPromises); - return nestedContributors.flat(); + if (!result.success) { + throw new NetworkError("Failed to create contribution details"); + } + + return { uri: result.data.uri, cid: result.data.cid }; } /** - * Resolves ContributionDetailsParams to a ResolvedContributionDetails. + * Resolves ContributionDetailsParams to a RefUri. * Creates a record if CreateContributionDetailsParams is provided. * @internal */ private async resolveContributionDetails( details: ContributionDetailsParams, onProgress?: (step: ProgressStep) => void, - ): Promise { + ): Promise { if (typeof details === "string") { // Inline role string return details; @@ -1366,20 +1346,7 @@ export class HypercertOperationsImpl extends EventEmitter imple // CreateContributionDetailsParams - auto-create record try { this.emitProgress(onProgress, { name: "createContribution", status: "start" }); - const { role, contributionDescription, startDate, endDate, ...extraProps } = details as { - role: string; - contributionDescription?: string; - startDate?: string; - endDate?: string; - [key: string]: unknown; - }; - const result = await this.addContribution({ - role, - description: contributionDescription, - startDate, - endDate, - ...extraProps, - }); + const result = await this.createContributionDetailsRecord(details); this.emitProgress(onProgress, { name: "createContribution", status: "success", @@ -1399,16 +1366,16 @@ export class HypercertOperationsImpl extends EventEmitter imple } /** - * Resolves ContributorIdentityParams to a ResolvedContributorIdentity. + * Resolves ContributorIdentityParams to a RefUri. * Creates a contributorInformation record if CreateContributorInformationParams is provided. * @internal */ private async resolveContributorIdentity( identity: ContributorIdentityParams, onProgress?: (step: ProgressStep) => void, - ): Promise { + ): Promise { if (typeof identity === "string") { - // we still store as contribtorInformation since it cant directly be a string + // we still store as contributorInformation since it can't directly be a string const result = await this.addContributorInformation({ identifier: identity }); return { uri: result.uri, cid: result.cid, $type: "com.atproto.repo.strongRef" }; } else if ("uri" in identity && "cid" in identity && !("identifier" in identity)) { @@ -1459,82 +1426,135 @@ export class HypercertOperationsImpl extends EventEmitter imple } /** - * Creates a contribution details record. + * Builds contributor entries from parameters by resolving identities and details. * - * This creates a standalone contribution details record that can be referenced - * from an activity's `contributors` array via a strong reference. + * This helper resolves contributor identities and contribution details, + * creating records as needed, then assembles them into the contributor entry + * format used in hypercert records. + * + * @param contributorParams - Array of contributor identity params (DID, StrongRef, or create params) + * @param detailsParams - Contribution details (inline role, StrongRef, or create params) + * @param weight - Optional contribution weight + * @param onProgress - Optional progress callback + * @returns Promise resolving to array of contributor entries ready for embedding + * @internal + * @protected + */ + protected async buildContributorEntries( + contributorParams: Array, + detailsParams: ContributionDetailsParams, + weight?: string, + onProgress?: (step: ProgressStep) => void, + ): Promise< + Array<{ + contributorIdentity: RefUri; + contributionWeight?: string; + contributionDetails?: RefUri; + }> + > { + const detailsRef = await this.resolveContributionDetails(detailsParams, onProgress); + const resolvedIdentities = await Promise.all( + contributorParams.map((identity) => this.resolveContributorIdentity(identity, onProgress)), + ); + return resolvedIdentities.map((identity) => ({ + contributorIdentity: identity, + contributionWeight: weight, + contributionDetails: detailsRef, + })); + } + + /** + * Attaches contributor entries to a hypercert by appending to its contributors array. + * + * Fetches the existing hypercert, merges new contributors with existing ones, + * and updates the hypercert record. + * + * @param hypercertUri - URI of the hypercert to update + * @param newContributors - Array of contributor entries to add + * @returns Promise resolving to update result with new URI and CID + * @throws {@link ValidationError} if URI format is invalid or validation fails + * @throws {@link NetworkError} if fetching or updating fails + * @internal + * @protected + */ + protected async attachContributorsToHypercert( + hypercertUri: string, + newContributors: Array<{ + contributorIdentity: RefUri; + contributionWeight?: string; + contributionDetails?: RefUri; + }>, + ): Promise { + const existing = await this.get(hypercertUri); + const existingContributors = existing.record.contributors || []; + const updatedContributors = [...existingContributors, ...newContributors]; + + return await this.update({ + uri: hypercertUri, + updates: { + contributors: updatedContributors, + }, + }); + } + + /** + * Adds contributors to an existing hypercert. + * + * This method creates or references contribution records and updates the hypercert + * to include the new contributors in its contributors array. * * @param params - Contribution parameters - * @param params.hypercertUri - Optional hypercert (unused, kept for backward compatibility) - * @param params.contributors - Array of contributor DIDs (unused, kept for backward compatibility) - * @param params.role - Role of the contributor (e.g., "coordinator", "implementer") - * @param params.description - Optional description of the contribution - * @returns Promise resolving to contribution details record URI and CID + * @returns Promise resolving to updated hypercert URI and CID * @throws {@link ValidationError} if validation fails * @throws {@link NetworkError} if the operation fails * - * @remarks - * In the new lexicon structure, contributions are stored differently: - * - Use `contributionDetails` for detailed contribution records (role, description, timeframe) - * - Use `contributorInformation` for contributor profiles (identifier, displayName, image) - * - Reference these from the activity's `contributors` array using strong refs + * @example Add multiple contributors with inline role + * ```typescript + * await repo.hypercerts.addContribution({ + * hypercertUri: "at://did:plc:abc/org.hypercerts.claim.activity/xyz", + * contributors: ["did:plc:user1", "did:plc:user2"], + * contributionDetails: "Developer", + * weight: "1.0" + * }); + * ``` * - * @example + * @example Add contributor with detailed contribution record * ```typescript * await repo.hypercerts.addContribution({ - * role: "implementer", - * description: "On-ground implementation team", + * hypercertUri: hypercertUri, + * contributors: [{ + * identifier: "did:plc:coordinator", + * displayName: "Alice", + * image: avatarBlob + * }], + * contributionDetails: { + * role: "Project Coordinator", + * contributionDescription: "Led coordination efforts", + * startDate: "2024-01-01", + * endDate: "2024-06-30" + * }, + * weight: "2.0" * }); * ``` */ async addContribution(params: { - hypercertUri?: string; - contributors?: string[]; - role: string; - description?: string; - startDate?: string; - endDate?: string; - [key: string]: unknown; - }): Promise { + hypercertUri: string; + contributors: Array; + contributionDetails: ContributionDetailsParams; + weight?: string; + onProgress?: (step: ProgressStep) => void; + }): Promise { try { - const createdAt = new Date().toISOString(); - // Extract known fields, spread the rest - const { - hypercertUri: _hypercertUri, - contributors: _contributors, - role, - description, - startDate, - endDate, - ...extraProps - } = params; - const contributionRecord: HypercertContributionDetails = { - $type: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, - role, - createdAt, - contributionDescription: description, - startDate, - endDate, - ...extraProps, - }; - - const validation = validate(contributionRecord, HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, "main", false); - if (!validation.success) { - throw new ValidationError(`Invalid contribution details record: ${validation.error?.message}`); - } - - const result = await this.agent.com.atproto.repo.createRecord({ - repo: this.repoDid, - collection: HYPERCERT_COLLECTIONS.CONTRIBUTION_DETAILS, - record: contributionRecord as Record, - }); - - if (!result.success) { - throw new NetworkError("Failed to create contribution details"); - } + const newContributors = await this.buildContributorEntries( + params.contributors, + params.contributionDetails, + params.weight, + params.onProgress, + ); + const result = await this.attachContributorsToHypercert(params.hypercertUri, newContributors); + this.emit("contributionCreated", { uri: result.uri, cid: result.cid }); - this.emit("contributionCreated", { uri: result.data.uri, cid: result.data.cid }); - return { uri: result.data.uri, cid: result.data.cid }; + return result; } catch (error) { if (error instanceof ValidationError || error instanceof NetworkError) throw error; throw new NetworkError( @@ -1733,11 +1753,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async updateMeasurement(uri: string, updates: UpdateMeasurementParams): Promise { try { - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); if (collection !== HYPERCERT_COLLECTIONS.MEASUREMENT) { throw new ValidationError( @@ -1979,11 +1995,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async getCollection(uri: string): Promise<{ uri: string; cid: string; record: HypercertCollection }> { try { - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); const result = await this.agent.com.atproto.repo.getRecord({ repo: this.repoDid, @@ -2132,11 +2144,7 @@ export class HypercertOperationsImpl extends EventEmitter imple async getProject(uri: string): Promise<{ uri: string; cid: string; record: HypercertCollection }> { try { // Parse URI - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); // Fetch record const result = await this.agent.com.atproto.repo.getRecord({ @@ -2263,11 +2271,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async updateProject(uri: string, updates: UpdateProjectParams): Promise { // Verify it's a project before updating - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); const existing = await this.agent.com.atproto.repo.getRecord({ repo: this.repoDid, @@ -2306,11 +2310,7 @@ export class HypercertOperationsImpl extends EventEmitter imple * ``` */ async deleteProject(uri: string): Promise { - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); const existing = await this.agent.com.atproto.repo.getRecord({ repo: this.repoDid, @@ -2372,11 +2372,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async updateCollection(uri: string, updates: UpdateCollectionParams): Promise { try { - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); const existing = await this.agent.com.atproto.repo.getRecord({ repo: this.repoDid, @@ -2482,11 +2478,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async deleteCollection(uri: string): Promise { try { - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); const result = await this.agent.com.atproto.repo.deleteRecord({ repo: this.repoDid, @@ -2517,11 +2509,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async attachLocationToCollection(uri: string, location: LocationParams): Promise { try { - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); const existing = await this.agent.com.atproto.repo.getRecord({ repo: this.repoDid, @@ -2573,11 +2561,7 @@ export class HypercertOperationsImpl extends EventEmitter imple */ async removeLocationFromCollection(uri: string): Promise { try { - const uriMatch = uri.match(/^at:\/\/([^/]+)\/([^/]+)\/(.+)$/); - if (!uriMatch) { - throw new ValidationError(`Invalid URI format: ${uri}`); - } - const [, , collection, rkey] = uriMatch; + const { collection, rkey } = this.parseAndValidateUri(uri); const existing = await this.agent.com.atproto.repo.getRecord({ repo: this.repoDid, diff --git a/packages/sdk-core/src/repository/interfaces.ts b/packages/sdk-core/src/repository/interfaces.ts index 72eab32e..c69d6b83 100644 --- a/packages/sdk-core/src/repository/interfaces.ts +++ b/packages/sdk-core/src/repository/interfaces.ts @@ -23,7 +23,7 @@ import type { CreateMeasurementParams, UpdateMeasurementParams, CreateAttachmentParams, - StrongRef, + RefUri, } from "../services/hypercerts/types.js"; import type { CreateResult, @@ -87,12 +87,6 @@ export interface CreateContributionDetailsParams { */ export type ContributionDetailsParams = string | { uri: string; cid: string } | CreateContributionDetailsParams; -/** - * Resolved contribution details (after processing). - * CreateContributionDetailsParams is converted to a StrongRef. - */ -export type ResolvedContributionDetails = string | { uri: string; cid: string; $type: "com.atproto.repo.strongRef" }; - // ============================================================================ // Contributor Identity Types // ============================================================================ @@ -133,12 +127,6 @@ export interface CreateContributorInformationParams { */ export type ContributorIdentityParams = string | { uri: string; cid: string } | CreateContributorInformationParams; -/** - * Resolved contributor identity (after processing). - * CreateContributorInformationParams is converted to a StrongRef. - */ -export type ResolvedContributorIdentity = string | { uri: string; cid: string; $type: "com.atproto.repo.strongRef" }; - // ============================================================================ // Hypercert Operation Types // ============================================================================ @@ -245,7 +233,7 @@ export interface CreateHypercertParams { * workScope: { uri: "at://did:plc:.../org.hypercerts.helper.workScopeTag/...", cid: "..." } * ``` */ - workScope?: string | StrongRef; + workScope?: RefUri; /** * Start date of the work period. @@ -394,6 +382,47 @@ export interface CreateHypercertParams { onProgress?: (step: ProgressStep) => void; } +/** + * Parameters for updating an existing hypercert record. + * + * Only includes fields that exist in the actual record schema (HypercertClaim). + * Excludes immutable fields like $type, createdAt, and rights. + * + * @remarks + * This differs from CreateHypercertParams: + * - Uses `contributors` (record field) not `contributions` (input field) + * - Uses `locations` as StrongRef array (resolved) not LocationParams (input) + * - All fields are optional (partial update) + * + * @example Basic field update + * ```typescript + * await repo.hypercerts.update({ + * uri: hypercertUri, + * updates: { + * title: "Updated Title", + * description: "New description" + * } + * }); + * ``` + * + * @example Update contributors array + * ```typescript + * await repo.hypercerts.update({ + * uri: hypercertUri, + * updates: { + * contributors: [ + * { + * contributorIdentity: { uri: "at://...", cid: "..." }, + * contributionDetails: "Developer", + * contributionWeight: "1.0" + * } + * ] + * } + * }); + * ``` + */ +export type UpdateHypercertParams = Partial>; + export interface CreateOrganizationParams { /** * Name of the organization @@ -882,11 +911,11 @@ export interface HypercertOperations extends EventEmitter { * * @param params - Update parameters * @param params.uri - AT-URI of the hypercert to update - * @param params.updates - Fields to update + * @param params.updates - Fields to update (from actual record schema) * @param params.image - New image, or `null` to remove * @returns Promise resolving to update result */ - update(params: { uri: string; updates: Partial; image?: Blob | null }): Promise; + update(params: { uri: string; updates: UpdateHypercertParams; image?: Blob | null }): Promise; /** * Gets a hypercert by URI. @@ -968,17 +997,26 @@ export interface HypercertOperations extends EventEmitter { addAttachment(attachment: CreateAttachmentParams): Promise; /** - * Creates a contribution record. + * Adds contributors to an existing hypercert. + * + * This method creates or references contribution records and updates the hypercert + * to include the new contributors in its contributors array. * * @param params - Contribution parameters - * @returns Promise resolving to contribution record result + * @param params.hypercertUri - URI of the hypercert to add contributors to + * @param params.contributors - Array of contributor identities (DID, StrongRef, or create params) + * @param params.contributionDetails - Contribution details (inline role, StrongRef, or create params) + * @param params.weight - Optional contribution weight + * @param params.onProgress - Optional progress callback + * @returns Promise resolving to updated hypercert URI and CID */ addContribution(params: { - hypercertUri?: string; - contributors: string[]; - role: string; - description?: string; - }): Promise; + hypercertUri: string; + contributors: Array; + contributionDetails: ContributionDetailsParams; + weight?: string; + onProgress?: (step: ProgressStep) => void; + }): Promise; /** * Creates a measurement record for a hypercert or other subject. diff --git a/packages/sdk-core/src/services/hypercerts/types.ts b/packages/sdk-core/src/services/hypercerts/types.ts index 9ebd1b77..be39aac6 100644 --- a/packages/sdk-core/src/services/hypercerts/types.ts +++ b/packages/sdk-core/src/services/hypercerts/types.ts @@ -81,6 +81,32 @@ export type { AppBskyRichtextFacet } from "@atproto/api"; export type StrongRef = ComAtprotoRepoStrongRef.Main; +/** + * A reference that can be either an AT-URI string or a resolved StrongRef. + * + * This type is commonly used for parameters that accept references to existing records, + * where the caller can provide either: + * - An AT-URI string (e.g., "at://did:plc:abc/org.hypercerts.claim.activity/xyz") + * - A StrongRef object with both URI and CID + * + * The string form is typically resolved to a StrongRef internally by fetching the CID. + * + * @example String URI + * ```typescript + * const ref: RefUri = "at://did:plc:abc/collection/rkey"; + * ``` + * + * @example StrongRef + * ```typescript + * const ref: RefUri = { + * $type: "com.atproto.repo.strongRef", + * uri: "at://...", + * cid: "bafy..." + * }; + * ``` + */ +export type RefUri = string | StrongRef; + /** * Hypercert claim (activity) record. * @@ -373,7 +399,7 @@ export type CreateLocationParams = OverrideProperties< * }; * ``` */ -export type LocationParams = StrongRef | string | CreateLocationParams; +export type LocationParams = RefUri | CreateLocationParams; /** * SDK input parameters for creating a collection. @@ -485,7 +511,7 @@ export type CreateMeasurementParams = OverrideProperties< * This will be converted to a StrongRef (uri + cid). * or if its a strong ref will be stored as is */ - subject: string | StrongRef; + subject: RefUri; /** * Geographic locations where the measurement was taken. @@ -547,7 +573,7 @@ export type UpdateMeasurementParams = Partial; + subjects: RefUri | RefUri[]; /** * Content of the attachment. @@ -662,4 +688,4 @@ export type UpdateAttachmentParams = Partial; * - StrongRef with uri and cid * - Full attachment params object to create new attachment */ -export type AttachmentParams = string | StrongRef | CreateAttachmentParams; +export type AttachmentParams = RefUri | CreateAttachmentParams; diff --git a/packages/sdk-core/tests/auth/OAuthClient.test.ts b/packages/sdk-core/tests/auth/OAuthClient.test.ts index 55d3bb01..cae2c672 100644 --- a/packages/sdk-core/tests/auth/OAuthClient.test.ts +++ b/packages/sdk-core/tests/auth/OAuthClient.test.ts @@ -60,7 +60,7 @@ describe("OAuthClient", () => { const client = new OAuthClient(config); // This will fail due to network, but we can verify the error handling await expect(client.authorize("test.bsky.social", { scope: "custom-scope" })).rejects.toThrow(); - }); + }, 10000); }); describe("callback", () => { diff --git a/packages/sdk-core/tests/core/SDK.test.ts b/packages/sdk-core/tests/core/SDK.test.ts index 267e2e8d..2565d450 100644 --- a/packages/sdk-core/tests/core/SDK.test.ts +++ b/packages/sdk-core/tests/core/SDK.test.ts @@ -80,7 +80,7 @@ describe("ATProtoSDK", () => { const sdk = new ATProtoSDK(config); // Will fail due to network, but should not throw ValidationError await expect(sdk.authorize(" test.bsky.social ")).rejects.not.toThrow(ValidationError); - }); + }, 10000); }); describe("restoreSession", () => { diff --git a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts index d4e33db0..a5d398ae 100644 --- a/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts +++ b/packages/sdk-core/tests/repository/HypercertOperationsImpl.test.ts @@ -2,7 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import type { Agent } from "@atproto/api"; import { HypercertOperationsImpl } from "../../src/repository/HypercertOperationsImpl.js"; import { NetworkError, ValidationError } from "../../src/core/errors.js"; -import type { BlobOperations } from "../../src/repository/interfaces.js"; +import type { + BlobOperations, + ContributorIdentityParams, + ContributionDetailsParams, +} from "../../src/repository/interfaces.js"; +import type { RefUri } from "../../src/services/hypercerts/types.js"; +import type { ProgressStep, UpdateResult } from "../../src/repository/types.js"; import { createMockAgent, createMockBlobOperations, TEST_REPO_DID } from "../utils/mocks.js"; /** @@ -14,6 +20,34 @@ function createWorkScopeString(scopes: string[]): string { return scopes.join(", "); } +/** + * Test-only subclass that exposes protected methods for unit testing. + * This allows us to test internal implementation details while maintaining + * type safety and avoiding ESLint warnings from 'as any' assertions. + */ +class TestableHypercertOperations extends HypercertOperationsImpl { + // Expose protected methods as public for testing + public async testBuildContributorEntries( + contributorParams: Array, + detailsParams: ContributionDetailsParams, + weight?: string, + onProgress?: (step: ProgressStep) => void, + ) { + return this.buildContributorEntries(contributorParams, detailsParams, weight, onProgress); + } + + public async testAttachContributorsToHypercert( + hypercertUri: string, + newContributors: Array<{ + contributorIdentity: RefUri; + contributionWeight?: string; + contributionDetails?: RefUri; + }>, + ): Promise { + return this.attachContributorsToHypercert(hypercertUri, newContributors); + } +} + // Mock the validate function from lexicon package vi.mock("@hypercerts-org/lexicon", async (importOriginal) => { const actual = await importOriginal(); @@ -26,12 +60,12 @@ vi.mock("@hypercerts-org/lexicon", async (importOriginal) => { describe("HypercertOperationsImpl", () => { let mockAgent: ReturnType; let mockBlobs: ReturnType; - let hypercertOps: HypercertOperationsImpl; + let hypercertOps: TestableHypercertOperations; beforeEach(() => { mockAgent = createMockAgent(vi); mockBlobs = createMockBlobOperations(vi); - hypercertOps = new HypercertOperationsImpl( + hypercertOps = new TestableHypercertOperations( mockAgent as unknown as Agent, TEST_REPO_DID, mockBlobs as BlobOperations, @@ -920,6 +954,30 @@ describe("HypercertOperationsImpl", () => { expect(handler).toHaveBeenCalled(); }); + + it("should update contributors array", async () => { + const newContributors = [ + { + contributorIdentity: { + uri: `at://${TEST_REPO_DID}/org.hypercerts.claim.contributorInformation/xyz`, + cid: "contributor-cid", + $type: "com.atproto.repo.strongRef" as const, + }, + contributionDetails: "Lead Developer", + contributionWeight: "2.0", + }, + ]; + + await hypercertOps.update({ + uri: `at://${TEST_REPO_DID}/org.hypercerts.claim.activity/abc123`, + updates: { + contributors: newContributors, + }, + }); + + const putCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(putCall.record.contributors).toEqual(newContributors); + }); }); describe("attachLocation", () => { @@ -1059,60 +1117,498 @@ describe("HypercertOperationsImpl", () => { }); }); - describe("addContribution", () => { + describe("buildContributorEntries (helper)", () => { + beforeEach(() => { + mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ + success: true, + data: { uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/xyz", cid: "record-cid" }, + }); + }); + + it("should resolve inline role string", async () => { + const result = await hypercertOps.testBuildContributorEntries( + ["did:plc:user1"], + "Developer", + undefined, + undefined, + ); + + expect(result).toHaveLength(1); + expect(result[0].contributionDetails).toBe("Developer"); + expect(result[0].contributorIdentity).toMatchObject({ + uri: expect.stringContaining("contributorInformation"), + cid: "record-cid", + }); + }); + + it("should create contributionDetails record from object", async () => { + mockAgent.com.atproto.repo.createRecord.mockResolvedValueOnce({ + success: true, + data: { uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/xyz", cid: "details-cid" }, + }); + + const result = await hypercertOps.testBuildContributorEntries( + ["did:plc:user1"], + { + role: "Project Lead", + contributionDescription: "Led project", + startDate: "2024-01-01", + endDate: "2024-06-30", + }, + "2.0", + undefined, + ); + + expect(result).toHaveLength(1); + expect(result[0].contributionDetails).toMatchObject({ + uri: expect.stringContaining("contributionDetails"), + cid: "details-cid", + }); + expect(result[0].contributionWeight).toBe("2.0"); + }); + + it("should handle multiple contributors with shared details", async () => { + const result = await hypercertOps.testBuildContributorEntries( + ["did:plc:user1", "did:plc:user2", "did:plc:user3"], + "Volunteer", + "1.0", + undefined, + ); + + expect(result).toHaveLength(3); + expect(result[0].contributionDetails).toBe("Volunteer"); + expect(result[1].contributionDetails).toBe("Volunteer"); + expect(result[2].contributionDetails).toBe("Volunteer"); + }); + + it("should create contributorInformation from create params", async () => { + await hypercertOps.testBuildContributorEntries( + [{ identifier: "did:plc:alice", displayName: "Alice Smith" }], + "Coordinator", + undefined, + undefined, + ); + + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "org.hypercerts.claim.contributorInformation", + record: expect.objectContaining({ + identifier: "did:plc:alice", + displayName: "Alice Smith", + }), + }), + ); + }); + }); + + describe("attachContributorsToHypercert (helper)", () => { beforeEach(() => { mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ success: true, data: { - uri: "at://did:plc:test/org.hypercerts.claim.record/abc", + uri: "at://did:plc:test/org.hypercerts.claim.activity/abc", cid: "hypercert-cid", value: { + $type: "org.hypercerts.claim.activity", title: "Test", description: "Test", + shortDescription: "Test", workScope: createWorkScopeString(["Climate"]), startDate: "2024-01-01", endDate: "2024-12-31", - createdAt: "2024-01-01", + rights: { uri: "at://rights", cid: "rights-cid" }, + contributors: [], + createdAt: "2024-01-01T00:00:00Z", + }, + }, + }); + + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ + success: true, + data: { uri: "at://did:plc:test/org.hypercerts.claim.activity/abc", cid: "updated-cid" }, + }); + }); + + it("should append contributors to empty array", async () => { + const newContributors = [ + { + contributorIdentity: { uri: "at://test/info/1", cid: "cid1", $type: "com.atproto.repo.strongRef" as const }, + contributionDetails: "Developer" as const, + contributionWeight: "1.0", + }, + ]; + + const result = await hypercertOps.testAttachContributorsToHypercert( + "at://did:plc:test/org.hypercerts.claim.activity/abc", + newContributors, + ); + + expect(result.uri).toBe("at://did:plc:test/org.hypercerts.claim.activity/abc"); + expect(result.cid).toBe("updated-cid"); + + const updateCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(updateCall.record.contributors).toHaveLength(1); + }); + + it("should preserve existing contributors when appending", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValueOnce({ + success: true, + data: { + uri: "at://did:plc:test/org.hypercerts.claim.activity/abc", + cid: "hypercert-cid", + value: { + $type: "org.hypercerts.claim.activity", + title: "Test", + description: "Test", + shortDescription: "Test", + workScope: createWorkScopeString(["Climate"]), + startDate: "2024-01-01", + endDate: "2024-12-31", + rights: { uri: "at://rights", cid: "rights-cid" }, + contributors: [ + { + contributorIdentity: { uri: "at://existing", cid: "cid0", $type: "com.atproto.repo.strongRef" }, + contributionDetails: "Existing Role", + }, + ], + createdAt: "2024-01-01T00:00:00Z", + }, + }, + }); + + const newContributors = [ + { + contributorIdentity: { uri: "at://test/info/1", cid: "cid1", $type: "com.atproto.repo.strongRef" as const }, + contributionDetails: "New Role" as const, + }, + ]; + + await hypercertOps.testAttachContributorsToHypercert( + "at://did:plc:test/org.hypercerts.claim.activity/abc", + newContributors, + ); + + const updateCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(updateCall.record.contributors).toHaveLength(2); + expect(updateCall.record.contributors[0].contributionDetails).toBe("Existing Role"); + expect(updateCall.record.contributors[1].contributionDetails).toBe("New Role"); + }); + + it("should handle hypercert with no contributors field", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValueOnce({ + success: true, + data: { + uri: "at://did:plc:test/org.hypercerts.claim.activity/abc", + cid: "hypercert-cid", + value: { + $type: "org.hypercerts.claim.activity", + title: "Test", + description: "Test", + shortDescription: "Test", + workScope: createWorkScopeString(["Climate"]), + startDate: "2024-01-01", + endDate: "2024-12-31", + rights: { uri: "at://rights", cid: "rights-cid" }, + // No contributors field + createdAt: "2024-01-01T00:00:00Z", + }, + }, + }); + + const newContributors = [ + { + contributorIdentity: { uri: "at://test/info/1", cid: "cid1", $type: "com.atproto.repo.strongRef" as const }, + contributionDetails: "Developer" as const, + }, + ]; + + await hypercertOps.testAttachContributorsToHypercert( + "at://did:plc:test/org.hypercerts.claim.activity/abc", + newContributors, + ); + + const updateCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(updateCall.record.contributors).toHaveLength(1); + }); + }); + + describe("addContribution (refactored)", () => { + beforeEach(() => { + // Mock getRecord - hypercert with empty contributors + mockAgent.com.atproto.repo.getRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/org.hypercerts.claim.activity/abc`, + cid: "hypercert-cid", + value: { + $type: "org.hypercerts.claim.activity", + title: "Test Hypercert", + description: "Test", + shortDescription: "Test", + workScope: createWorkScopeString(["Climate"]), + startDate: "2024-01-01", + endDate: "2024-12-31", + rights: { uri: "at://rights", cid: "rights-cid" }, + contributors: [], + createdAt: "2024-01-01T00:00:00Z", }, }, }); + // Mock createRecord - for contributionDetails/contributorInformation mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ success: true, - data: { uri: "at://did:plc:test/org.hypercerts.claim.contribution/xyz", cid: "contrib-cid" }, + data: { + uri: `at://${TEST_REPO_DID}/org.hypercerts.claim.contributionDetails/xyz`, + cid: "record-cid", + }, + }); + + // Mock putRecord - for updating hypercert + mockAgent.com.atproto.repo.putRecord.mockResolvedValue({ + success: true, + data: { + uri: `at://${TEST_REPO_DID}/org.hypercerts.claim.activity/abc`, + cid: "updated-cid", + }, }); }); - it("should create a contribution linked to hypercert", async () => { + it("should add single contributor with inline role", async () => { const result = await hypercertOps.addContribution({ - hypercertUri: "at://did:plc:test/org.hypercerts.claim.record/abc", - contributors: ["did:plc:contributor1"], - role: "Developer", - description: "Built the thing", + hypercertUri: `at://${TEST_REPO_DID}/org.hypercerts.claim.activity/abc`, + contributors: ["did:plc:user1"], + contributionDetails: "Developer", }); - expect(result.uri).toContain("contribution"); + // Verify hypercert was updated + expect(mockAgent.com.atproto.repo.putRecord).toHaveBeenCalledWith( + expect.objectContaining({ + repo: TEST_REPO_DID, + collection: "org.hypercerts.claim.activity", + record: expect.objectContaining({ + contributors: expect.arrayContaining([ + expect.objectContaining({ + contributionDetails: "Developer", + }), + ]), + }), + }), + ); + + expect(result.uri).toBe(`at://${TEST_REPO_DID}/org.hypercerts.claim.activity/abc`); + expect(result.cid).toBe("updated-cid"); }); - it("should create standalone contribution without hypercert", async () => { - const result = await hypercertOps.addContribution({ - contributors: ["did:plc:contributor1"], - role: "Developer", + it("should add multiple contributors with shared role", async () => { + await hypercertOps.addContribution({ + hypercertUri: "at://did:plc:test/org.hypercerts.claim.activity/abc", + contributors: ["did:plc:user1", "did:plc:user2", "did:plc:user3"], + contributionDetails: "Volunteer", }); - expect(result.uri).toBeDefined(); + const updateCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(updateCall.record.contributors).toHaveLength(3); }); - it("should emit contributionCreated event", async () => { - const handler = vi.fn(); - hypercertOps.on("contributionCreated", handler); + it("should create contributionDetails record from object", async () => { + await hypercertOps.addContribution({ + hypercertUri: "at://did:plc:test/org.hypercerts.claim.activity/abc", + contributors: ["did:plc:user1"], + contributionDetails: { + role: "Project Lead", + contributionDescription: "Led the project", + startDate: "2024-01-01", + endDate: "2024-06-30", + }, + }); + // Verify contributionDetails record was created + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "org.hypercerts.claim.contributionDetails", + record: expect.objectContaining({ + role: "Project Lead", + contributionDescription: "Led the project", + }), + }), + ); + + // Verify hypercert references the created record + const updateCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(updateCall.record.contributors[0].contributionDetails).toMatchObject({ + uri: expect.stringContaining("contributionDetails"), + cid: "record-cid", + }); + }); + + it("should use existing contributionDetails StrongRef", async () => { await hypercertOps.addContribution({ - contributors: ["did:plc:test"], - role: "Tester", + hypercertUri: "at://did:plc:test/org.hypercerts.claim.activity/abc", + contributors: ["did:plc:user1"], + contributionDetails: { + uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/existing", + cid: "existing-cid", + }, }); - expect(handler).toHaveBeenCalled(); + // Verify no new contributionDetails record created (only contributorInformation) + const createCalls = mockAgent.com.atproto.repo.createRecord.mock.calls; + expect(createCalls.some((call) => call[0].collection === "org.hypercerts.claim.contributionDetails")).toBe(false); + + // Verify hypercert uses the provided StrongRef + const updateCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(updateCall.record.contributors[0].contributionDetails).toMatchObject({ + uri: "at://did:plc:test/org.hypercerts.claim.contributionDetails/existing", + cid: "existing-cid", + }); + }); + + it("should create contributorInformation from create params", async () => { + await hypercertOps.addContribution({ + hypercertUri: "at://did:plc:test/org.hypercerts.claim.activity/abc", + contributors: [ + { + identifier: "did:plc:alice", + displayName: "Alice Smith", + }, + ], + contributionDetails: "Coordinator", + }); + + expect(mockAgent.com.atproto.repo.createRecord).toHaveBeenCalledWith( + expect.objectContaining({ + collection: "org.hypercerts.claim.contributorInformation", + record: expect.objectContaining({ + identifier: "did:plc:alice", + displayName: "Alice Smith", + }), + }), + ); + }); + + it("should include weight when provided", async () => { + await hypercertOps.addContribution({ + hypercertUri: "at://did:plc:test/org.hypercerts.claim.activity/abc", + contributors: ["did:plc:user1"], + contributionDetails: "Lead Developer", + weight: "2.5", + }); + + const updateCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(updateCall.record.contributors[0]).toMatchObject({ + contributionWeight: "2.5", + }); + }); + + it("should append to existing contributors without replacing", async () => { + // Mock hypercert with existing contributor + mockAgent.com.atproto.repo.getRecord.mockResolvedValueOnce({ + success: true, + data: { + uri: "at://did:plc:test/org.hypercerts.claim.activity/abc", + cid: "hypercert-cid", + value: { + $type: "org.hypercerts.claim.activity", + title: "Test", + description: "Test", + shortDescription: "Test", + workScope: createWorkScopeString(["Climate"]), + startDate: "2024-01-01", + endDate: "2024-12-31", + rights: { uri: "at://rights", cid: "rights-cid" }, + contributors: [ + { + contributorIdentity: { + uri: "at://did:plc:test/org.hypercerts.claim.contributorInformation/existing", + cid: "existing-cid", + $type: "com.atproto.repo.strongRef", + }, + contributionDetails: "Existing Role", + }, + ], + createdAt: "2024-01-01T00:00:00Z", + }, + }, + }); + + await hypercertOps.addContribution({ + hypercertUri: "at://did:plc:test/org.hypercerts.claim.activity/abc", + contributors: ["did:plc:new-user"], + contributionDetails: "New Role", + }); + + const updateCall = mockAgent.com.atproto.repo.putRecord.mock.calls[0][0]; + expect(updateCall.record.contributors).toHaveLength(2); + expect(updateCall.record.contributors[0].contributionDetails).toBe("Existing Role"); + }); + + it("should throw NetworkError if hypercert not found", async () => { + mockAgent.com.atproto.repo.getRecord.mockResolvedValueOnce({ + success: false, + }); + + await expect( + hypercertOps.addContribution({ + hypercertUri: "at://did:plc:test/org.hypercerts.claim.activity/missing", + contributors: ["did:plc:user"], + contributionDetails: "Developer", + }), + ).rejects.toThrow(NetworkError); + }); + + it("should throw NetworkError if update fails", async () => { + mockAgent.com.atproto.repo.putRecord.mockResolvedValueOnce({ + success: false, + }); + + await expect( + hypercertOps.addContribution({ + hypercertUri: "at://did:plc:test/org.hypercerts.claim.activity/abc", + contributors: ["did:plc:user"], + contributionDetails: "Developer", + }), + ).rejects.toThrow(NetworkError); + }); + }); + + describe("processContributors (integration check)", () => { + it("should still work after refactor", async () => { + // Set up mocks for record creation + mockAgent.com.atproto.repo.createRecord.mockResolvedValue({ + success: true, + data: { uri: "at://test/record", cid: "test-cid" }, + }); + + // Test the create() flow which uses processContributors + await hypercertOps.create({ + title: "Test", + description: "Test", + shortDescription: "Test", + workScope: createWorkScopeString(["Climate"]), + startDate: "2024-01-01", + endDate: "2024-12-31", + rights: { name: "CC-BY", type: "license", description: "Open" }, + contributions: [ + { + contributors: ["did:plc:user1", "did:plc:user2"], + contributionDetails: "Developer", + weight: "1.0", + }, + { + contributors: ["did:plc:user3"], + contributionDetails: { role: "Lead", contributionDescription: "Led project" }, + weight: "2.0", + }, + ], + }); + + // Verify contributors were processed and included in hypercert + const createCall = mockAgent.com.atproto.repo.createRecord.mock.calls.find( + (call) => call[0].collection === "org.hypercerts.claim.activity", + ); + expect(createCall).toBeDefined(); + expect(createCall![0].record.contributors).toHaveLength(3); // 2 + 1 }); }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b3291103..d079c500 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -154,20 +154,20 @@ packages: '@atcute/atproto@3.1.10': resolution: {integrity: sha512-+GKZpOc0PJcdWMQEkTfg/rSNDAAHxmAUGBl60g2az15etqJn5WaUPNGFE2sB7hKpwi5Ue2h/L0OacINcE/JDDQ==} - '@atcute/bluesky@3.2.15': - resolution: {integrity: sha512-H4RW3WffjfdKvOZ9issEUQnuSR4KfuAwwJnYu0fclA9VDa99JTJ+pa8tTl9lFeBV9DINtWJAx7rdIbICoVCstQ==} + '@atcute/bluesky@3.2.17': + resolution: {integrity: sha512-Li+RsPkcRNC6AnNlqOGnlmAcjSwBdXIKFubJL1nwACDngKNXG4ooGL5cvzeekdDEfHmtFhS/tyZNaUx9QXYEUw==} - '@atcute/leaflet@1.0.16': - resolution: {integrity: sha512-YS0+93C+bG2AlB1M5Cvf8v7GMnP/67l5G+RUQQltXydqugcAEW6ZgasXN/ZlsTJJl5tdWBTS4HsRYwdS576SkA==} + '@atcute/leaflet@1.0.17': + resolution: {integrity: sha512-aCykf/vCRY19B8S42VEoyIvUR2CuOsH/RQcDF8u8rHZvmGfc4qgcAAxriHxiVrlinLNhxzhzuliq2avVxHiv8g==} - '@atcute/lexicons@1.2.6': - resolution: {integrity: sha512-s76UQd8D+XmHIzrjD9CJ9SOOeeLPHc+sMmcj7UFakAW/dDFXc579fcRdRfuUKvXBL5v1Gs2VgDdlh/IvvQZAwA==} + '@atcute/lexicons@1.2.7': + resolution: {integrity: sha512-gCvkSMI1F1zx7xXa59iPiSKMH3L5Hga6iurGqQjaQbE2V/np/2QuDqQzt96TNbWfaFAXE9f9oY+0z3ljf/bweA==} - '@atcute/uint8array@1.0.6': - resolution: {integrity: sha512-ucfRBQc7BFT8n9eCyGOzDHEMKF/nZwhS2pPao4Xtab1ML3HdFYcX2DM1tadCzas85QTGxHe5urnUAAcNKGRi9A==} + '@atcute/uint8array@1.1.0': + resolution: {integrity: sha512-JtHXIVW6LPU9FMWp7SgE4HbUs3uV2WdfkK/2RWdEGjr4EgMV50P3FdU6fPeGlTfDNBJVYMIsuD2wwaKRPV/Aqg==} - '@atcute/util-text@0.0.1': - resolution: {integrity: sha512-t1KZqvn0AYy+h2KcJyHnKF9aEqfRfMUmyY8j1ELtAEIgqN9CxINAjxnoRCJIFUlvWzb+oY3uElQL/Vyk3yss0g==} + '@atcute/util-text@1.1.0': + resolution: {integrity: sha512-34G9KD5Z9f7oEdFpZOmqrMnU86p8ne6LlxJowfZzKNszRcl1GH+FtEPh3N1woelJT2SkPXMK2anwT8DESTluwA==} '@atproto-labs/did-resolver@0.2.4': resolution: {integrity: sha512-sbXxBnAJWsKv/FEGG6a/WLz7zQYUr1vA2TXvNnPwwJQJCjPwEJMOh1vM22wBr185Phy7D2GD88PcRokn7eUVyw==} @@ -201,8 +201,8 @@ packages: '@atproto/api@0.17.7': resolution: {integrity: sha512-V+OJBZq9chcrD21xk1bUa6oc5DSKfQj5DmUPf5rmZncqL1w9ZEbS38H5cMyqqdhfgo2LWeDRdZHD0rvNyJsIaw==} - '@atproto/common-web@0.4.13': - resolution: {integrity: sha512-TewRUyB/dVJ5PtI3QmJzEgT3wDsvpnLJ+48hPl+LuUueJPamZevXKJN6dFjtbKAMFRnl2bKfdsf79qwvdSaLKQ==} + '@atproto/common-web@0.4.16': + resolution: {integrity: sha512-Ufvaff5JgxUyUyTAG0/3o7ltpy3lnZ1DvLjyAnvAf+hHfiK7OMQg+8byr+orN+KP9MtIQaRTsCgYPX+PxMKUoA==} '@atproto/common-web@0.4.5': resolution: {integrity: sha512-Tx0xUafLm3vRvOQpbBl5eb9V8xlC7TaRXs6dAulHRkDG3Kb+P9qn3pkDteq+aeMshbVXbVa1rm3Ok4vFyuoyYA==} @@ -222,14 +222,14 @@ packages: '@atproto/lex-data@0.0.1': resolution: {integrity: sha512-DrS/8cQcQs3s5t9ELAFNtyDZ8/PdiCx47ALtFEP2GnX2uCBHZRkqWG7xmu6ehjc787nsFzZBvlnz3T/gov5fGA==} - '@atproto/lex-data@0.0.9': - resolution: {integrity: sha512-1slwe4sG0cyWtsq16+rBoWIxNDqGPkkvN+PV6JuzA7dgUK9bjUmXBGQU4eZlUPSS43X1Nhmr/9VjgKmEzU9vDw==} + '@atproto/lex-data@0.0.11': + resolution: {integrity: sha512-4+KTtHdqwlhiTKA7D4SACea4jprsNpCQsNALW09wsZ6IHhCDGO5tr1cmV+QnLYe3G3mu1E1yXHXbPUHrUUDT/A==} '@atproto/lex-json@0.0.1': resolution: {integrity: sha512-ivcF7+pDRuD/P97IEKQ/9TruunXj0w58Khvwk3M6psaI5eZT6LRsRZ4cWcKaXiFX4SHnjy+x43g0f7pPtIsERg==} - '@atproto/lex-json@0.0.9': - resolution: {integrity: sha512-Q2v1EVZcnd+ndyZj1r2UlGikA7q6It24CFPLbxokcf5Ba4RBupH8IkkQX7mqUDSRWPgQdmZYIdW9wUln+MKDqw==} + '@atproto/lex-json@0.0.11': + resolution: {integrity: sha512-2IExAoQ4KsR5fyPa1JjIvtR316PvdgRH/l3BVGLBd3cSxM3m5MftIv1B6qZ9HjNiK60SgkWp0mi9574bTNDhBQ==} '@atproto/lexicon@0.5.2': resolution: {integrity: sha512-lRmJgMA8f5j7VB5Iu5cp188ald5FuI4FlmZ7nn6EBrk1dgOstWVrI5Ft6K3z2vjyLZRG6nzknlsw+tDP63p7bQ==} @@ -2225,28 +2225,28 @@ snapshots: '@atcute/atproto@3.1.10': dependencies: - '@atcute/lexicons': 1.2.6 + '@atcute/lexicons': 1.2.7 - '@atcute/bluesky@3.2.15': + '@atcute/bluesky@3.2.17': dependencies: '@atcute/atproto': 3.1.10 - '@atcute/lexicons': 1.2.6 + '@atcute/lexicons': 1.2.7 - '@atcute/leaflet@1.0.16': + '@atcute/leaflet@1.0.17': dependencies: '@atcute/atproto': 3.1.10 - '@atcute/lexicons': 1.2.6 + '@atcute/lexicons': 1.2.7 - '@atcute/lexicons@1.2.6': + '@atcute/lexicons@1.2.7': dependencies: - '@atcute/uint8array': 1.0.6 - '@atcute/util-text': 0.0.1 + '@atcute/uint8array': 1.1.0 + '@atcute/util-text': 1.1.0 '@standard-schema/spec': 1.1.0 esm-env: 1.2.2 - '@atcute/uint8array@1.0.6': {} + '@atcute/uint8array@1.1.0': {} - '@atcute/util-text@0.0.1': + '@atcute/util-text@1.1.0': dependencies: unicode-segmenter: 0.14.5 @@ -2308,10 +2308,10 @@ snapshots: tlds: 1.261.0 zod: 3.25.76 - '@atproto/common-web@0.4.13': + '@atproto/common-web@0.4.16': dependencies: - '@atproto/lex-data': 0.0.9 - '@atproto/lex-json': 0.0.9 + '@atproto/lex-data': 0.0.11 + '@atproto/lex-json': 0.0.11 '@atproto/syntax': 0.4.3 zod: 3.25.76 @@ -2349,21 +2349,21 @@ snapshots: uint8arrays: 3.0.0 unicode-segmenter: 0.14.0 - '@atproto/lex-data@0.0.9': + '@atproto/lex-data@0.0.11': dependencies: multiformats: 9.9.0 tslib: 2.8.1 uint8arrays: 3.0.0 - unicode-segmenter: 0.14.0 + unicode-segmenter: 0.14.5 '@atproto/lex-json@0.0.1': dependencies: '@atproto/lex-data': 0.0.1 tslib: 2.8.1 - '@atproto/lex-json@0.0.9': + '@atproto/lex-json@0.0.11': dependencies: - '@atproto/lex-data': 0.0.9 + '@atproto/lex-data': 0.0.11 tslib: 2.8.1 '@atproto/lexicon@0.5.2': @@ -2376,7 +2376,7 @@ snapshots: '@atproto/lexicon@0.6.1': dependencies: - '@atproto/common-web': 0.4.13 + '@atproto/common-web': 0.4.16 '@atproto/syntax': 0.4.3 iso-datestring-validator: 2.2.2 multiformats: 9.9.0 @@ -2775,8 +2775,8 @@ snapshots: '@hypercerts-org/lexicon@0.10.0-beta.13(@atproto/xrpc@0.7.6)': dependencies: - '@atcute/bluesky': 3.2.15 - '@atcute/leaflet': 1.0.16 + '@atcute/bluesky': 3.2.17 + '@atcute/leaflet': 1.0.17 '@atproto/lexicon': 0.6.1 '@atproto/xrpc': 0.7.6 multiformats: 13.4.2