diff --git a/migrations/1679941023060_add-invalid-job-status.ts b/migrations/1679941023060_add-invalid-job-status.ts new file mode 100644 index 00000000..d206de54 --- /dev/null +++ b/migrations/1679941023060_add-invalid-job-status.ts @@ -0,0 +1,10 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { MigrationBuilder, ColumnDefinitions } from 'node-pg-migrate'; + +export const shorthands: ColumnDefinitions | undefined = undefined; + +export function up(pgm: MigrationBuilder): void { + pgm.addTypeValue('job_status', 'invalid'); +} + +export function down(pgm: MigrationBuilder): void {} diff --git a/src/api/schemas.ts b/src/api/schemas.ts index 9d412f6c..e9477b7e 100644 --- a/src/api/schemas.ts +++ b/src/api/schemas.ts @@ -158,6 +158,13 @@ export const TokenNotFoundResponse = Type.Object( { title: 'Token Not Found Response' } ); +export const ContractNotFoundResponse = Type.Object( + { + error: Type.Literal('Contract not found'), + }, + { title: 'Contract Not Found Response' } +); + export const TokenNotProcessedResponse = Type.Object( { error: Type.Literal('Token metadata fetch in progress'), @@ -172,9 +179,32 @@ export const TokenLocaleNotFoundResponse = Type.Object( { title: 'Locale Not Found Response' } ); -export const TokenErrorResponse = Type.Union( - [TokenNotProcessedResponse, TokenLocaleNotFoundResponse], - { title: 'Token Error Response' } +export const InvalidTokenContractResponse = Type.Object( + { + error: Type.Literal('Token contract is invalid or does not conform to its token standard'), + }, + { title: 'Invalid Token Contract Response' } +); + +export const InvalidTokenMetadataResponse = Type.Object( + { + error: Type.Literal('Token metadata is unreachable or does not conform to SIP-016'), + }, + { title: 'Invalid Token Metadata Response' } +); + +export const NotFoundResponse = Type.Union([TokenNotFoundResponse, ContractNotFoundResponse], { + title: 'Not Found Error Response', +}); + +export const ErrorResponse = Type.Union( + [ + TokenNotProcessedResponse, + TokenLocaleNotFoundResponse, + InvalidTokenContractResponse, + InvalidTokenMetadataResponse, + ], + { title: 'Error Response' } ); export const FtMetadataResponse = Type.Object( diff --git a/src/api/util/errors.ts b/src/api/util/errors.ts index a0da178a..223aaaf5 100644 --- a/src/api/util/errors.ts +++ b/src/api/util/errors.ts @@ -1,12 +1,19 @@ import { Value } from '@sinclair/typebox/value'; import { FastifyReply } from 'fastify'; import { - TokenErrorResponse, + InvalidTokenContractResponse, + InvalidTokenMetadataResponse, + ErrorResponse, TokenLocaleNotFoundResponse, TokenNotFoundResponse, TokenNotProcessedResponse, + NotFoundResponse, + ContractNotFoundResponse, } from '../schemas'; import { + ContractNotFoundError, + InvalidContractError, + InvalidTokenError, TokenLocaleNotFoundError, TokenNotFoundError, TokenNotProcessedError, @@ -14,18 +21,24 @@ import { import { setReplyNonCacheable } from './cache'; export const TokenErrorResponseSchema = { - 404: TokenNotFoundResponse, - 422: TokenErrorResponse, + 404: NotFoundResponse, + 422: ErrorResponse, }; export async function generateTokenErrorResponse(error: any, reply: FastifyReply) { setReplyNonCacheable(reply); if (error instanceof TokenNotFoundError) { await reply.code(404).send(Value.Create(TokenNotFoundResponse)); + } else if (error instanceof ContractNotFoundError) { + await reply.code(404).send(Value.Create(ContractNotFoundResponse)); } else if (error instanceof TokenNotProcessedError) { await reply.code(422).send(Value.Create(TokenNotProcessedResponse)); } else if (error instanceof TokenLocaleNotFoundError) { await reply.code(422).send(Value.Create(TokenLocaleNotFoundResponse)); + } else if (error instanceof InvalidContractError) { + await reply.code(422).send(Value.Create(InvalidTokenContractResponse)); + } else if (error instanceof InvalidTokenError) { + await reply.code(422).send(Value.Create(InvalidTokenMetadataResponse)); } else { throw error; } diff --git a/src/pg/errors.ts b/src/pg/errors.ts index 4670f938..9411eb3d 100644 --- a/src/pg/errors.ts +++ b/src/pg/errors.ts @@ -5,6 +5,13 @@ export class TokenNotFoundError extends Error { } } +export class ContractNotFoundError extends Error { + constructor() { + super(); + this.name = this.constructor.name; + } +} + export class TokenNotProcessedError extends Error { constructor() { super(); @@ -18,3 +25,17 @@ export class TokenLocaleNotFoundError extends Error { this.name = this.constructor.name; } } + +export class InvalidContractError extends Error { + constructor() { + super(); + this.name = this.constructor.name; + } +} + +export class InvalidTokenError extends Error { + constructor() { + super(); + this.name = this.constructor.name; + } +} diff --git a/src/pg/pg-store.ts b/src/pg/pg-store.ts index a8561077..0c3ee8bd 100644 --- a/src/pg/pg-store.ts +++ b/src/pg/pg-store.ts @@ -27,7 +27,14 @@ import { } from './types'; import { connectPostgres } from './postgres-tools'; import { BasePgStore } from './postgres-tools/base-pg-store'; -import { TokenLocaleNotFoundError, TokenNotFoundError, TokenNotProcessedError } from './errors'; +import { + ContractNotFoundError, + InvalidContractError, + InvalidTokenError, + TokenLocaleNotFoundError, + TokenNotFoundError, + TokenNotProcessedError, +} from './errors'; import { runMigrations } from './migrations'; /** @@ -138,6 +145,20 @@ export class PgStore extends BasePgStore { locale?: string; }): Promise { return await this.sqlTransaction(async sql => { + // Is the contract invalid? + const contractJobStatus = await sql<{ status: DbJobStatus }[]>` + SELECT status + FROM jobs + INNER JOIN smart_contracts ON jobs.smart_contract_id = smart_contracts.id + WHERE smart_contracts.principal = ${args.contractPrincipal} + `; + if (contractJobStatus.count === 0) { + throw new ContractNotFoundError(); + } + if (contractJobStatus[0].status === DbJobStatus.invalid) { + throw new InvalidContractError(); + } + // Get token id const tokenIdRes = await sql<{ id: number }[]>` SELECT tokens.id FROM tokens @@ -148,11 +169,14 @@ export class PgStore extends BasePgStore { if (tokenIdRes.count === 0) { throw new TokenNotFoundError(); } - if (args.locale && !(await this.isTokenLocaleAvailable(tokenIdRes[0].id, args.locale))) { + const tokenId = tokenIdRes[0].id; + // Is the locale valid? + if (args.locale && !(await this.isTokenLocaleAvailable(tokenId, args.locale))) { throw new TokenLocaleNotFoundError(); } + // Get metadata return await this.getTokenMetadataBundleInternal( - tokenIdRes[0].id, + tokenId, args.contractPrincipal, args.locale ); @@ -449,16 +473,27 @@ export class PgStore extends BasePgStore { smartContractPrincipal: string, locale?: string ): Promise { - const tokenRes = await this.sql` - SELECT ${this.sql(TOKENS_COLUMNS)} FROM tokens WHERE id = ${tokenId} + // Is token invalid? + const tokenJobStatus = await this.sql<{ status: string }[]>` + SELECT status FROM jobs WHERE token_id = ${tokenId} `; - if (tokenRes.count === 0) { + if (tokenJobStatus.count === 0) { throw new TokenNotFoundError(); } + const status = tokenJobStatus[0].status; + if (status === DbJobStatus.invalid) { + throw new InvalidTokenError(); + } + // Get token + const tokenRes = await this.sql` + SELECT ${this.sql(TOKENS_COLUMNS)} FROM tokens WHERE id = ${tokenId} + `; const token = tokenRes[0]; - if (!token.updated_at) { + // Is it still waiting to be processed? + if (!token.updated_at && (status === DbJobStatus.queued || status === DbJobStatus.pending)) { throw new TokenNotProcessedError(); } + // Get metadata let localeBundle: DbMetadataLocaleBundle | undefined; const metadataRes = await this.sql` SELECT ${this.sql(METADATA_COLUMNS)} FROM metadata @@ -484,7 +519,7 @@ export class PgStore extends BasePgStore { } const smartContract = await this.getSmartContract({ principal: smartContractPrincipal }); if (!smartContract) { - throw new TokenNotFoundError(); + throw new ContractNotFoundError(); } return { token, diff --git a/src/pg/types.ts b/src/pg/types.ts index e7e6901b..225d19be 100644 --- a/src/pg/types.ts +++ b/src/pg/types.ts @@ -14,6 +14,7 @@ export enum DbJobStatus { queued = 'queued', done = 'done', failed = 'failed', + invalid = 'invalid', } export enum DbTokenType { diff --git a/src/token-processor/queue/job/job.ts b/src/token-processor/queue/job/job.ts index 31c8ca95..62428c0c 100644 --- a/src/token-processor/queue/job/job.ts +++ b/src/token-processor/queue/job/job.ts @@ -3,6 +3,7 @@ import { logger } from '../../../logger'; import { PgStore } from '../../../pg/pg-store'; import { stopwatch } from '../../../pg/postgres-tools/helpers'; import { DbJob, DbJobStatus } from '../../../pg/types'; +import { UserError } from '../../util/errors'; import { RetryableJobError } from '../errors'; import { getJobQueueProcessingMode, JobQueueProcessingMode } from '../helpers'; @@ -34,8 +35,7 @@ export abstract class Job { * shouldn't be overridden. */ async work(): Promise { - let processingFinished = false; - let finishedWithError = false; + let status: DbJobStatus | undefined; const sw = stopwatch(); // This block will catch any and all errors that are generated while processing the job. Each of @@ -44,7 +44,7 @@ export abstract class Job { // `processed = true` so it can be picked up by the queue at a later time. try { await this.handler(); - processingFinished = true; + status = DbJobStatus.done; } catch (error) { if (error instanceof RetryableJobError) { const retries = await this.db.increaseJobRetryCount({ id: this.job.id }); @@ -59,18 +59,17 @@ export abstract class Job { await this.updateStatus(DbJobStatus.pending); } else { logger.warn(error, `Job ${this.description()} max retries reached, giving up`); - processingFinished = true; - finishedWithError = true; + status = DbJobStatus.failed; } + } else if (error instanceof UserError) { + logger.error(error, `User error on Job ${this.description()}`); + status = DbJobStatus.invalid; } else { - // Something more serious happened, mark this token as failed. logger.error(error, `Job ${this.description()}`); - processingFinished = true; - finishedWithError = true; + status = DbJobStatus.failed; } } finally { - if (processingFinished) { - const status = finishedWithError ? DbJobStatus.failed : DbJobStatus.done; + if (status) { if (await this.updateStatus(status)) { logger.info(`Job ${this.description()} ${status} in ${sw.getElapsed()}ms`); } diff --git a/src/token-processor/stacks-node/stacks-node-rpc-client.ts b/src/token-processor/stacks-node/stacks-node-rpc-client.ts index e0dc5322..e0701185 100644 --- a/src/token-processor/stacks-node/stacks-node-rpc-client.ts +++ b/src/token-processor/stacks-node/stacks-node-rpc-client.ts @@ -7,7 +7,7 @@ import { import { request, errors } from 'undici'; import { ENV } from '../../env'; import { RetryableJobError } from '../queue/errors'; -import { HttpError, JsonParseError } from '../util/errors'; +import { StacksNodeClarityError, HttpError, StacksNodeJsonParseError } from '../util/errors'; interface ReadOnlyContractCallSuccessResponse { okay: true; @@ -80,7 +80,7 @@ export class StacksNodeRpcClient { try { return JSON.parse(text) as ReadOnlyContractCallResponse; } catch (error) { - throw new JsonParseError(`JSON parse error ${url}: ${text}`); + throw new StacksNodeJsonParseError(`JSON parse error ${url}: ${text}`); } } catch (error) { if (error instanceof errors.UndiciError) { @@ -107,7 +107,7 @@ export class StacksNodeRpcClient { `Runtime error while calling read-only function ${functionName}` ); } - throw new Error(`Read-only error ${functionName}: ${result.cause}`); + throw new StacksNodeClarityError(`Read-only error ${functionName}: ${result.cause}`); } return decodeClarityValue(result.result); } @@ -128,7 +128,7 @@ export class StacksNodeRpcClient { if (unwrappedClarityValue.type_id === ClarityTypeID.UInt) { return unwrappedClarityValue; } - throw new Error( + throw new StacksNodeClarityError( `Unexpected Clarity type '${unwrappedClarityValue.type_id}' while unwrapping uint` ); } @@ -143,7 +143,7 @@ export class StacksNodeRpcClient { } else if (unwrappedClarityValue.type_id === ClarityTypeID.OptionalNone) { return undefined; } - throw new Error( + throw new StacksNodeClarityError( `Unexpected Clarity type '${unwrappedClarityValue.type_id}' while unwrapping string` ); } diff --git a/src/token-processor/util/errors.ts b/src/token-processor/util/errors.ts index 333b2f15..d87394be 100644 --- a/src/token-processor/util/errors.ts +++ b/src/token-processor/util/errors.ts @@ -1,8 +1,11 @@ import { errors } from 'undici'; import { parseRetryAfterResponseHeader } from './helpers'; +/** Tags an error as a user error i.e. caused by a bad contract, incorrect SIP-016 metadata, etc. */ +export class UserError extends Error {} + /** Thrown when fetching metadata exceeds the max allowed byte size */ -export class MetadataSizeExceededError extends Error { +export class MetadataSizeExceededError extends UserError { constructor(message: string) { super(); this.message = message; @@ -11,7 +14,7 @@ export class MetadataSizeExceededError extends Error { } /** Thrown when fetching metadata exceeds the max allowed timeout */ -export class MetadataTimeoutError extends Error { +export class MetadataTimeoutError extends UserError { constructor(message: string) { super(); this.message = message; @@ -20,7 +23,15 @@ export class MetadataTimeoutError extends Error { } /** Thrown when there is a parse error that prevented metadata processing */ -export class MetadataParseError extends Error { +export class MetadataParseError extends UserError { + constructor(message: string) { + super(); + this.message = message; + this.name = this.constructor.name; + } +} + +export class StacksNodeClarityError extends UserError { constructor(message: string) { super(); this.message = message; @@ -51,7 +62,7 @@ export class TooManyRequestsHttpError extends HttpError { } } -export class JsonParseError extends Error { +export class StacksNodeJsonParseError extends Error { constructor(message: string) { super(); this.message = message; diff --git a/src/token-processor/util/metadata-helpers.ts b/src/token-processor/util/metadata-helpers.ts index 62f6fedc..51bfe9cd 100644 --- a/src/token-processor/util/metadata-helpers.ts +++ b/src/token-processor/util/metadata-helpers.ts @@ -24,7 +24,7 @@ import { RawMetadataAttributesCType, RawMetadataPropertiesCType, RawMetadataCType, - RawMetadataType, + RawMetadata, } from './types'; const METADATA_FETCH_HTTP_AGENT = new Agent({ @@ -219,7 +219,7 @@ export async function fetchMetadata(httpUrl: URL): Promise { } } -export async function getMetadataFromUri(token_uri: string): Promise { +export async function getMetadataFromUri(token_uri: string): Promise { // Support JSON embedded in a Data URL if (new URL(token_uri).protocol === 'data:') { const dataUrl = parseDataUrl(token_uri); @@ -239,29 +239,21 @@ export async function getMetadataFromUri(token_uri: string): Promise; +export type RawMetadata = Static; export const RawMetadataCType = TypeCompiler.Compile(RawMetadata); // Raw metadata localization types. @@ -44,7 +44,7 @@ const RawMetadataProperties = Type.Record(Type.String(), Type.Any()); export const RawMetadataPropertiesCType = TypeCompiler.Compile(RawMetadataProperties); export type RawMetadataLocale = { - metadata: RawMetadataType; + metadata: RawMetadata; locale?: string; default: boolean; uri: string; diff --git a/tests/ft.test.ts b/tests/ft.test.ts index d5907058..035b383d 100644 --- a/tests/ft.test.ts +++ b/tests/ft.test.ts @@ -20,7 +20,7 @@ describe('FT routes', () => { await db.close(); }); - const enqueueToken = async () => { + const enqueueContract = async () => { const values: DbSmartContractInsert = { principal: 'SP2SYHR84SDJJDK8M09HFS4KBFXPPCX9H7RZ9YVTS.hello-world', sip: DbSipNumber.sip010, @@ -29,6 +29,10 @@ describe('FT routes', () => { block_height: 1, }; await db.insertAndEnqueueSmartContract({ values }); + }; + + const enqueueToken = async () => { + await enqueueContract(); await db.insertAndEnqueueSequentialTokens({ smart_contract_id: 1, token_count: 1n, @@ -36,13 +40,23 @@ describe('FT routes', () => { }); }; + test('contract not found', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/metadata/v1/ft/SP2SYHR84SDJJDK8M09HFS4KBFXPPCX9H7RZ9YVTS.hello-world', + }); + expect(response.statusCode).toBe(404); + expect(response.json().error).toMatch(/Contract not found/); + }); + test('token not found', async () => { + await enqueueContract(); const response = await fastify.inject({ method: 'GET', url: '/metadata/v1/ft/SP2SYHR84SDJJDK8M09HFS4KBFXPPCX9H7RZ9YVTS.hello-world', }); expect(response.statusCode).toBe(404); - expect(response.json()).toStrictEqual({ error: 'Token not found' }); + expect(response.json().error).toMatch(/Token not found/); }); test('token not processed', async () => { @@ -55,6 +69,28 @@ describe('FT routes', () => { expect(response.json()).toStrictEqual({ error: 'Token metadata fetch in progress' }); }); + test('invalid contract', async () => { + await enqueueToken(); + await db.sql`UPDATE jobs SET status = 'invalid' WHERE id = 1`; + const response = await fastify.inject({ + method: 'GET', + url: '/metadata/v1/ft/SP2SYHR84SDJJDK8M09HFS4KBFXPPCX9H7RZ9YVTS.hello-world', + }); + expect(response.statusCode).toBe(422); + expect(response.json().error).toMatch(/Token contract/); + }); + + test('invalid token metadata', async () => { + await enqueueToken(); + await db.sql`UPDATE jobs SET status = 'invalid' WHERE id = 2`; + const response = await fastify.inject({ + method: 'GET', + url: '/metadata/v1/ft/SP2SYHR84SDJJDK8M09HFS4KBFXPPCX9H7RZ9YVTS.hello-world', + }); + expect(response.statusCode).toBe(422); + expect(response.json().error).toMatch(/Token metadata/); + }); + test('locale not found', async () => { await enqueueToken(); await db.updateProcessedTokenWithMetadata({ diff --git a/tests/job.test.ts b/tests/job.test.ts index ab91266d..417f24cf 100644 --- a/tests/job.test.ts +++ b/tests/job.test.ts @@ -4,6 +4,7 @@ import { PgStore } from '../src/pg/pg-store'; import { DbJob, DbSipNumber, DbSmartContractInsert } from '../src/pg/types'; import { RetryableJobError } from '../src/token-processor/queue/errors'; import { Job } from '../src/token-processor/queue/job/job'; +import { UserError } from '../src/token-processor/util/errors'; class TestRetryableJob extends Job { description(): string { @@ -14,6 +15,15 @@ class TestRetryableJob extends Job { } } +class TestUserErrorJob extends Job { + description(): string { + return 'test'; + } + handler(): Promise { + throw new UserError('test'); + } +} + class TestDbJob extends Job { description(): string { return 'test'; @@ -45,6 +55,17 @@ describe('Job', () => { await db.close(); }); + test('valid job marked as done', async () => { + const job = new TestDbJob({ db, job: dbJob }); + + await expect(job.work()).resolves.not.toThrow(); + const jobs1 = await db.getPendingJobBatch({ limit: 1 }); + expect(jobs1.length).toBe(0); + + const dbJob1 = await db.getJob({ id: dbJob.id }); + expect(dbJob1?.status).toBe('done'); + }); + test('retryable error increases retry_count', async () => { const job = new TestRetryableJob({ db, job: dbJob }); @@ -59,6 +80,17 @@ describe('Job', () => { expect(jobs2[0].status).toBe('pending'); }); + test('user error marks job invalid', async () => { + const job = new TestUserErrorJob({ db, job: dbJob }); + + await expect(job.work()).resolves.not.toThrow(); + const jobs1 = await db.getPendingJobBatch({ limit: 1 }); + expect(jobs1.length).toBe(0); + + const dbJob1 = await db.getJob({ id: dbJob.id }); + expect(dbJob1?.status).toBe('invalid'); + }); + test('retry_count limit reached marks entry as failed', async () => { ENV.JOB_QUEUE_STRICT_MODE = false; ENV.JOB_QUEUE_MAX_RETRIES = 0; diff --git a/tests/metadata-helpers.test.ts b/tests/metadata-helpers.test.ts index 0aecc190..5edb9912 100644 --- a/tests/metadata-helpers.test.ts +++ b/tests/metadata-helpers.test.ts @@ -44,9 +44,7 @@ describe('Metadata Helpers', () => { .reply(200, '[{"test-bad-json": true}]'); setGlobalDispatcher(agent); - await expect(getMetadataFromUri('http://test.io/1.json')).rejects.toThrow( - /Invalid raw metadata JSON schema/ - ); + await expect(getMetadataFromUri('http://test.io/1.json')).rejects.toThrow(/JSON parse error/); }); test('throws metadata http errors', async () => { diff --git a/tests/nft.test.ts b/tests/nft.test.ts index 7401afc2..cc27f194 100644 --- a/tests/nft.test.ts +++ b/tests/nft.test.ts @@ -20,7 +20,7 @@ describe('NFT routes', () => { await db.close(); }); - const enqueueToken = async () => { + const enqueueContract = async () => { const values: DbSmartContractInsert = { principal: 'SP2SYHR84SDJJDK8M09HFS4KBFXPPCX9H7RZ9YVTS.hello-world', sip: DbSipNumber.sip009, @@ -29,6 +29,10 @@ describe('NFT routes', () => { block_height: 1, }; await db.insertAndEnqueueSmartContract({ values }); + }; + + const enqueueToken = async () => { + await enqueueContract(); await db.insertAndEnqueueSequentialTokens({ smart_contract_id: 1, token_count: 1n, @@ -36,13 +40,23 @@ describe('NFT routes', () => { }); }; + test('contract not found', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/metadata/v1/nft/SP2SYHR84SDJJDK8M09HFS4KBFXPPCX9H7RZ9YVTS.hello-world/1', + }); + expect(response.statusCode).toBe(404); + expect(response.json().error).toMatch(/Contract not found/); + }); + test('token not found', async () => { + await enqueueContract(); const response = await fastify.inject({ method: 'GET', url: '/metadata/v1/nft/SP2SYHR84SDJJDK8M09HFS4KBFXPPCX9H7RZ9YVTS.hello-world/1', }); expect(response.statusCode).toBe(404); - expect(response.json()).toStrictEqual({ error: 'Token not found' }); + expect(response.json().error).toMatch(/Token not found/); }); test('token not processed', async () => { @@ -55,6 +69,28 @@ describe('NFT routes', () => { expect(response.json()).toStrictEqual({ error: 'Token metadata fetch in progress' }); }); + test('invalid contract', async () => { + await enqueueToken(); + await db.sql`UPDATE jobs SET status = 'invalid' WHERE id = 1`; + const response = await fastify.inject({ + method: 'GET', + url: '/metadata/v1/nft/SP2SYHR84SDJJDK8M09HFS4KBFXPPCX9H7RZ9YVTS.hello-world/1', + }); + expect(response.statusCode).toBe(422); + expect(response.json().error).toMatch(/Token contract/); + }); + + test('invalid token metadata', async () => { + await enqueueToken(); + await db.sql`UPDATE jobs SET status = 'invalid' WHERE id = 2`; + const response = await fastify.inject({ + method: 'GET', + url: '/metadata/v1/nft/SP2SYHR84SDJJDK8M09HFS4KBFXPPCX9H7RZ9YVTS.hello-world/1', + }); + expect(response.statusCode).toBe(422); + expect(response.json().error).toMatch(/Token metadata/); + }); + test('locale not found', async () => { await enqueueToken(); await db.updateProcessedTokenWithMetadata({ diff --git a/tests/sft.test.ts b/tests/sft.test.ts index 640bb6b6..618276a4 100644 --- a/tests/sft.test.ts +++ b/tests/sft.test.ts @@ -20,7 +20,7 @@ describe('SFT routes', () => { await db.close(); }); - const enqueueToken = async () => { + const enqueueContract = async () => { const address = 'SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9'; const contractId = 'key-alex-autoalex-v1'; const values: DbSmartContractInsert = { @@ -31,6 +31,10 @@ describe('SFT routes', () => { block_height: 1, }; await db.insertAndEnqueueSmartContract({ values }); + }; + + const enqueueToken = async () => { + await enqueueContract(); await db.insertAndEnqueueTokenArray([ { smart_contract_id: 1, @@ -40,13 +44,23 @@ describe('SFT routes', () => { ]); }; + test('contract not found', async () => { + const response = await fastify.inject({ + method: 'GET', + url: '/metadata/v1/nft/SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.key-alex-autoalex-v1/1', + }); + expect(response.statusCode).toBe(404); + expect(response.json().error).toMatch(/Contract not found/); + }); + test('token not found', async () => { + await enqueueContract(); const response = await fastify.inject({ method: 'GET', - url: '/metadata/v1/sft/SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.key-alex-autoalex-v1/1', + url: '/metadata/v1/nft/SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.key-alex-autoalex-v1/1', }); expect(response.statusCode).toBe(404); - expect(response.json()).toStrictEqual({ error: 'Token not found' }); + expect(response.json().error).toMatch(/Token not found/); }); test('token not processed', async () => { @@ -59,6 +73,28 @@ describe('SFT routes', () => { expect(response.json()).toStrictEqual({ error: 'Token metadata fetch in progress' }); }); + test('invalid contract', async () => { + await enqueueToken(); + await db.sql`UPDATE jobs SET status = 'invalid' WHERE id = 1`; + const response = await fastify.inject({ + method: 'GET', + url: '/metadata/v1/sft/SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.key-alex-autoalex-v1/1', + }); + expect(response.statusCode).toBe(422); + expect(response.json().error).toMatch(/Token contract/); + }); + + test('invalid token metadata', async () => { + await enqueueToken(); + await db.sql`UPDATE jobs SET status = 'invalid' WHERE id = 2`; + const response = await fastify.inject({ + method: 'GET', + url: '/metadata/v1/sft/SP3K8BC0PPEVCV7NZ6QSRWPQ2JE9E5B6N3PA0KBR9.key-alex-autoalex-v1/1', + }); + expect(response.statusCode).toBe(422); + expect(response.json().error).toMatch(/Token metadata/); + }); + test('locale not found', async () => { await enqueueToken(); await db.updateProcessedTokenWithMetadata({ diff --git a/tests/stacks-node-rpc-client.test.ts b/tests/stacks-node-rpc-client.test.ts index f35eda10..2c7899da 100644 --- a/tests/stacks-node-rpc-client.test.ts +++ b/tests/stacks-node-rpc-client.test.ts @@ -10,7 +10,7 @@ import { MockAgent, setGlobalDispatcher } from 'undici'; import { ENV } from '../src/env'; import { RetryableJobError } from '../src/token-processor/queue/errors'; import { StacksNodeRpcClient } from '../src/token-processor/stacks-node/stacks-node-rpc-client'; -import { HttpError, JsonParseError } from '../src/token-processor/util/errors'; +import { HttpError, StacksNodeJsonParseError } from '../src/token-processor/util/errors'; describe('StacksNodeRpcClient', () => { const nodeUrl = `http://${ENV.STACKS_NODE_RPC_HOST}:${ENV.STACKS_NODE_RPC_PORT}`; @@ -109,7 +109,7 @@ describe('StacksNodeRpcClient', () => { } catch (error) { expect(error).toBeInstanceOf(RetryableJobError); const err = error as RetryableJobError; - expect(err.cause).toBeInstanceOf(JsonParseError); + expect(err.cause).toBeInstanceOf(StacksNodeJsonParseError); } });