From 31911ab1b0c822a36bdba1469baa2624765e804b Mon Sep 17 00:00:00 2001 From: Caleb Van Lue Date: Wed, 4 Jun 2025 20:28:07 -0400 Subject: [PATCH] allow endpoints to search and suggest releases --- package-lock.json | 4 + .../1748119392860-CreateReleaseTable.ts | 27 +- ...48120797831-AddCollectionWantlistTables.ts | 77 +++-- .../1748123334600-AddSortableFields.ts | 275 +++++++++++++----- src/discogs/discogs-api.service.ts | 104 +++++++ src/discogs/discogs.controller.ts | 72 +++++ src/discogs/dto/search-releases.dto.ts | 64 ++++ src/discogs/dto/suggest-release.dto.ts | 25 ++ src/discogs/tests/discogs-api.service.spec.ts | 251 ++++++++++++++++ src/discogs/tests/discogs.controller.spec.ts | 195 ++++++++++++- 10 files changed, 983 insertions(+), 111 deletions(-) create mode 100644 src/discogs/dto/search-releases.dto.ts create mode 100644 src/discogs/dto/suggest-release.dto.ts diff --git a/package-lock.json b/package-lock.json index 2e6528f..9875d25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,10 @@ "tsconfig-paths": "^4.2.0", "typescript": "^5.7.3", "typescript-eslint": "^8.20.0" + }, + "engines": { + "node": ">=20.0.0", + "npm": ">=10.0.0" } }, "node_modules/@ampproject/remapping": { diff --git a/src/database/migrations/1748119392860-CreateReleaseTable.ts b/src/database/migrations/1748119392860-CreateReleaseTable.ts index ea6f580..869ca20 100644 --- a/src/database/migrations/1748119392860-CreateReleaseTable.ts +++ b/src/database/migrations/1748119392860-CreateReleaseTable.ts @@ -1,16 +1,21 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; export class CreateReleaseTable1748119392860 implements MigrationInterface { - name = 'CreateReleaseTable1748119392860' + name = 'CreateReleaseTable1748119392860'; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE "releases" ("id" SERIAL NOT NULL, "discogs_id" integer NOT NULL, "title" character varying NOT NULL, "year" integer, "thumb_url" character varying, "cover_image_url" character varying, "artists" json NOT NULL, "labels" json, "formats" json, "genres" json, "styles" json, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_c0b05a1fcbfcc0b5310dec3e400" UNIQUE ("discogs_id"), CONSTRAINT "PK_6b6fc2599a5a281dd44a7d64016" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE INDEX "IDX_c0b05a1fcbfcc0b5310dec3e40" ON "releases" ("discogs_id") `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_c0b05a1fcbfcc0b5310dec3e40"`); - await queryRunner.query(`DROP TABLE "releases"`); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "releases" ("id" SERIAL NOT NULL, "discogs_id" integer NOT NULL, "title" character varying NOT NULL, "year" integer, "thumb_url" character varying, "cover_image_url" character varying, "artists" json NOT NULL, "labels" json, "formats" json, "genres" json, "styles" json, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "UQ_c0b05a1fcbfcc0b5310dec3e400" UNIQUE ("discogs_id"), CONSTRAINT "PK_6b6fc2599a5a281dd44a7d64016" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_c0b05a1fcbfcc0b5310dec3e40" ON "releases" ("discogs_id") `, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_c0b05a1fcbfcc0b5310dec3e40"`, + ); + await queryRunner.query(`DROP TABLE "releases"`); + } } diff --git a/src/database/migrations/1748120797831-AddCollectionWantlistTables.ts b/src/database/migrations/1748120797831-AddCollectionWantlistTables.ts index 5c628b8..864d195 100644 --- a/src/database/migrations/1748120797831-AddCollectionWantlistTables.ts +++ b/src/database/migrations/1748120797831-AddCollectionWantlistTables.ts @@ -1,28 +1,57 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; -export class AddCollectionWantlistTables1748120797831 implements MigrationInterface { - name = 'AddCollectionWantlistTables1748120797831' +export class AddCollectionWantlistTables1748120797831 + implements MigrationInterface +{ + name = 'AddCollectionWantlistTables1748120797831'; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`CREATE TABLE "user_collections" ("id" SERIAL NOT NULL, "user_id" character varying NOT NULL, "release_id" integer NOT NULL, "discogs_instance_id" integer, "folder_id" integer NOT NULL DEFAULT '0', "rating" smallint NOT NULL DEFAULT '0', "notes" text, "customFields" json, "date_added" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_0f50c79662214ef4d0f14956980" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE INDEX "IDX_64c12326d36a9ead157b3757d4" ON "user_collections" ("user_id") `); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_a62fffd30546ee94fb9cf557da" ON "user_collections" ("user_id", "release_id") `); - await queryRunner.query(`CREATE TABLE "user_wantlists" ("id" SERIAL NOT NULL, "user_id" character varying NOT NULL, "release_id" integer NOT NULL, "notes" text, "date_added" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_ed19af2fe54473eec7a9bbb48b0" PRIMARY KEY ("id"))`); - await queryRunner.query(`CREATE INDEX "IDX_f1f891440bf3768ac812ed92ab" ON "user_wantlists" ("user_id") `); - await queryRunner.query(`CREATE UNIQUE INDEX "IDX_e5bbdf79a43fa87f67849ce292" ON "user_wantlists" ("user_id", "release_id") `); - await queryRunner.query(`ALTER TABLE "user_collections" ADD CONSTRAINT "FK_8b43f7ada150082e862a3a0e1a4" FOREIGN KEY ("release_id") REFERENCES "releases"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - await queryRunner.query(`ALTER TABLE "user_wantlists" ADD CONSTRAINT "FK_ff0cb82493c8a0bf603d446c05f" FOREIGN KEY ("release_id") REFERENCES "releases"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "user_wantlists" DROP CONSTRAINT "FK_ff0cb82493c8a0bf603d446c05f"`); - await queryRunner.query(`ALTER TABLE "user_collections" DROP CONSTRAINT "FK_8b43f7ada150082e862a3a0e1a4"`); - await queryRunner.query(`DROP INDEX "public"."IDX_e5bbdf79a43fa87f67849ce292"`); - await queryRunner.query(`DROP INDEX "public"."IDX_f1f891440bf3768ac812ed92ab"`); - await queryRunner.query(`DROP TABLE "user_wantlists"`); - await queryRunner.query(`DROP INDEX "public"."IDX_a62fffd30546ee94fb9cf557da"`); - await queryRunner.query(`DROP INDEX "public"."IDX_64c12326d36a9ead157b3757d4"`); - await queryRunner.query(`DROP TABLE "user_collections"`); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "user_collections" ("id" SERIAL NOT NULL, "user_id" character varying NOT NULL, "release_id" integer NOT NULL, "discogs_instance_id" integer, "folder_id" integer NOT NULL DEFAULT '0', "rating" smallint NOT NULL DEFAULT '0', "notes" text, "customFields" json, "date_added" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_0f50c79662214ef4d0f14956980" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_64c12326d36a9ead157b3757d4" ON "user_collections" ("user_id") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_a62fffd30546ee94fb9cf557da" ON "user_collections" ("user_id", "release_id") `, + ); + await queryRunner.query( + `CREATE TABLE "user_wantlists" ("id" SERIAL NOT NULL, "user_id" character varying NOT NULL, "release_id" integer NOT NULL, "notes" text, "date_added" TIMESTAMP, "created_at" TIMESTAMP NOT NULL DEFAULT now(), "updated_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_ed19af2fe54473eec7a9bbb48b0" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f1f891440bf3768ac812ed92ab" ON "user_wantlists" ("user_id") `, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_e5bbdf79a43fa87f67849ce292" ON "user_wantlists" ("user_id", "release_id") `, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" ADD CONSTRAINT "FK_8b43f7ada150082e862a3a0e1a4" FOREIGN KEY ("release_id") REFERENCES "releases"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "user_wantlists" ADD CONSTRAINT "FK_ff0cb82493c8a0bf603d446c05f" FOREIGN KEY ("release_id") REFERENCES "releases"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "user_wantlists" DROP CONSTRAINT "FK_ff0cb82493c8a0bf603d446c05f"`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" DROP CONSTRAINT "FK_8b43f7ada150082e862a3a0e1a4"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_e5bbdf79a43fa87f67849ce292"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_f1f891440bf3768ac812ed92ab"`, + ); + await queryRunner.query(`DROP TABLE "user_wantlists"`); + await queryRunner.query( + `DROP INDEX "public"."IDX_a62fffd30546ee94fb9cf557da"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_64c12326d36a9ead157b3757d4"`, + ); + await queryRunner.query(`DROP TABLE "user_collections"`); + } } diff --git a/src/database/migrations/1748123334600-AddSortableFields.ts b/src/database/migrations/1748123334600-AddSortableFields.ts index 0abcf49..c818d56 100644 --- a/src/database/migrations/1748123334600-AddSortableFields.ts +++ b/src/database/migrations/1748123334600-AddSortableFields.ts @@ -1,80 +1,205 @@ -import { MigrationInterface, QueryRunner } from "typeorm"; +import { MigrationInterface, QueryRunner } from 'typeorm'; export class AddSortableFields1748123334600 implements MigrationInterface { - name = 'AddSortableFields1748123334600' + name = 'AddSortableFields1748123334600'; - public async up(queryRunner: QueryRunner): Promise { - await queryRunner.query(`ALTER TABLE "releases" ADD "primary_artist" character varying`); - await queryRunner.query(`ALTER TABLE "releases" ADD "all_artists" character varying`); - await queryRunner.query(`ALTER TABLE "releases" ADD "primary_genre" character varying`); - await queryRunner.query(`ALTER TABLE "releases" ADD "primary_style" character varying`); - await queryRunner.query(`ALTER TABLE "releases" ADD "primary_format" character varying`); - await queryRunner.query(`ALTER TABLE "releases" ADD "vinyl_color" character varying`); - await queryRunner.query(`ALTER TABLE "releases" ADD "catalog_number" character varying`); - await queryRunner.query(`ALTER TABLE "releases" ADD "record_label" character varying`); - await queryRunner.query(`ALTER TABLE "user_collections" ADD "title" character varying`); - await queryRunner.query(`ALTER TABLE "user_collections" ADD "primary_artist" character varying`); - await queryRunner.query(`ALTER TABLE "user_collections" ADD "all_artists" character varying`); - await queryRunner.query(`ALTER TABLE "user_collections" ADD "year" integer`); - await queryRunner.query(`ALTER TABLE "user_collections" ADD "primary_genre" character varying`); - await queryRunner.query(`ALTER TABLE "user_collections" ADD "primary_format" character varying`); - await queryRunner.query(`ALTER TABLE "user_collections" ADD "vinyl_color" character varying`); - await queryRunner.query(`ALTER TABLE "user_wantlists" ADD "title" character varying`); - await queryRunner.query(`ALTER TABLE "user_wantlists" ADD "primary_artist" character varying`); - await queryRunner.query(`ALTER TABLE "user_wantlists" ADD "all_artists" character varying`); - await queryRunner.query(`ALTER TABLE "user_wantlists" ADD "year" integer`); - await queryRunner.query(`ALTER TABLE "user_wantlists" ADD "primary_genre" character varying`); - await queryRunner.query(`ALTER TABLE "user_wantlists" ADD "primary_format" character varying`); - await queryRunner.query(`ALTER TABLE "user_wantlists" ADD "vinyl_color" character varying`); - await queryRunner.query(`CREATE INDEX "IDX_87dd10b9eb5b63b60a14ed17a4" ON "releases" ("title") `); - await queryRunner.query(`CREATE INDEX "IDX_eda1661a006a8303c050552085" ON "releases" ("year") `); - await queryRunner.query(`CREATE INDEX "IDX_ec278e8abcbf006515a277f1ff" ON "releases" ("primary_artist") `); - await queryRunner.query(`CREATE INDEX "IDX_73d8426a1c155f7fc969148c6b" ON "user_collections" ("user_id", "year") `); - await queryRunner.query(`CREATE INDEX "IDX_6783bb510bad97bf415eff392a" ON "user_collections" ("user_id", "title") `); - await queryRunner.query(`CREATE INDEX "IDX_e1086300ea6f5981cbae2814fd" ON "user_collections" ("user_id", "primary_artist") `); - await queryRunner.query(`CREATE INDEX "IDX_f497fb00f03e11ec8052ddee27" ON "user_collections" ("user_id", "rating") `); - await queryRunner.query(`CREATE INDEX "IDX_add9afabf4ee1595400208b46f" ON "user_collections" ("user_id", "date_added") `); - await queryRunner.query(`CREATE INDEX "IDX_8215b482f7ae75c712f1e2ac19" ON "user_wantlists" ("user_id", "year") `); - await queryRunner.query(`CREATE INDEX "IDX_568cf08059102d1e38543df9a9" ON "user_wantlists" ("user_id", "title") `); - await queryRunner.query(`CREATE INDEX "IDX_1dddcfb81ad42bae8e6d10f473" ON "user_wantlists" ("user_id", "primary_artist") `); - await queryRunner.query(`CREATE INDEX "IDX_991c59c010ecbd15562a70229f" ON "user_wantlists" ("user_id", "date_added") `); - } - - public async down(queryRunner: QueryRunner): Promise { - await queryRunner.query(`DROP INDEX "public"."IDX_991c59c010ecbd15562a70229f"`); - await queryRunner.query(`DROP INDEX "public"."IDX_1dddcfb81ad42bae8e6d10f473"`); - await queryRunner.query(`DROP INDEX "public"."IDX_568cf08059102d1e38543df9a9"`); - await queryRunner.query(`DROP INDEX "public"."IDX_8215b482f7ae75c712f1e2ac19"`); - await queryRunner.query(`DROP INDEX "public"."IDX_add9afabf4ee1595400208b46f"`); - await queryRunner.query(`DROP INDEX "public"."IDX_f497fb00f03e11ec8052ddee27"`); - await queryRunner.query(`DROP INDEX "public"."IDX_e1086300ea6f5981cbae2814fd"`); - await queryRunner.query(`DROP INDEX "public"."IDX_6783bb510bad97bf415eff392a"`); - await queryRunner.query(`DROP INDEX "public"."IDX_73d8426a1c155f7fc969148c6b"`); - await queryRunner.query(`DROP INDEX "public"."IDX_ec278e8abcbf006515a277f1ff"`); - await queryRunner.query(`DROP INDEX "public"."IDX_eda1661a006a8303c050552085"`); - await queryRunner.query(`DROP INDEX "public"."IDX_87dd10b9eb5b63b60a14ed17a4"`); - await queryRunner.query(`ALTER TABLE "user_wantlists" DROP COLUMN "vinyl_color"`); - await queryRunner.query(`ALTER TABLE "user_wantlists" DROP COLUMN "primary_format"`); - await queryRunner.query(`ALTER TABLE "user_wantlists" DROP COLUMN "primary_genre"`); - await queryRunner.query(`ALTER TABLE "user_wantlists" DROP COLUMN "year"`); - await queryRunner.query(`ALTER TABLE "user_wantlists" DROP COLUMN "all_artists"`); - await queryRunner.query(`ALTER TABLE "user_wantlists" DROP COLUMN "primary_artist"`); - await queryRunner.query(`ALTER TABLE "user_wantlists" DROP COLUMN "title"`); - await queryRunner.query(`ALTER TABLE "user_collections" DROP COLUMN "vinyl_color"`); - await queryRunner.query(`ALTER TABLE "user_collections" DROP COLUMN "primary_format"`); - await queryRunner.query(`ALTER TABLE "user_collections" DROP COLUMN "primary_genre"`); - await queryRunner.query(`ALTER TABLE "user_collections" DROP COLUMN "year"`); - await queryRunner.query(`ALTER TABLE "user_collections" DROP COLUMN "all_artists"`); - await queryRunner.query(`ALTER TABLE "user_collections" DROP COLUMN "primary_artist"`); - await queryRunner.query(`ALTER TABLE "user_collections" DROP COLUMN "title"`); - await queryRunner.query(`ALTER TABLE "releases" DROP COLUMN "record_label"`); - await queryRunner.query(`ALTER TABLE "releases" DROP COLUMN "catalog_number"`); - await queryRunner.query(`ALTER TABLE "releases" DROP COLUMN "vinyl_color"`); - await queryRunner.query(`ALTER TABLE "releases" DROP COLUMN "primary_format"`); - await queryRunner.query(`ALTER TABLE "releases" DROP COLUMN "primary_style"`); - await queryRunner.query(`ALTER TABLE "releases" DROP COLUMN "primary_genre"`); - await queryRunner.query(`ALTER TABLE "releases" DROP COLUMN "all_artists"`); - await queryRunner.query(`ALTER TABLE "releases" DROP COLUMN "primary_artist"`); - } + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "releases" ADD "primary_artist" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "releases" ADD "all_artists" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "releases" ADD "primary_genre" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "releases" ADD "primary_style" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "releases" ADD "primary_format" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "releases" ADD "vinyl_color" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "releases" ADD "catalog_number" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "releases" ADD "record_label" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" ADD "title" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" ADD "primary_artist" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" ADD "all_artists" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" ADD "year" integer`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" ADD "primary_genre" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" ADD "primary_format" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" ADD "vinyl_color" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_wantlists" ADD "title" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_wantlists" ADD "primary_artist" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_wantlists" ADD "all_artists" character varying`, + ); + await queryRunner.query(`ALTER TABLE "user_wantlists" ADD "year" integer`); + await queryRunner.query( + `ALTER TABLE "user_wantlists" ADD "primary_genre" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_wantlists" ADD "primary_format" character varying`, + ); + await queryRunner.query( + `ALTER TABLE "user_wantlists" ADD "vinyl_color" character varying`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_87dd10b9eb5b63b60a14ed17a4" ON "releases" ("title") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_eda1661a006a8303c050552085" ON "releases" ("year") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_ec278e8abcbf006515a277f1ff" ON "releases" ("primary_artist") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_73d8426a1c155f7fc969148c6b" ON "user_collections" ("user_id", "year") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_6783bb510bad97bf415eff392a" ON "user_collections" ("user_id", "title") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_e1086300ea6f5981cbae2814fd" ON "user_collections" ("user_id", "primary_artist") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_f497fb00f03e11ec8052ddee27" ON "user_collections" ("user_id", "rating") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_add9afabf4ee1595400208b46f" ON "user_collections" ("user_id", "date_added") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_8215b482f7ae75c712f1e2ac19" ON "user_wantlists" ("user_id", "year") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_568cf08059102d1e38543df9a9" ON "user_wantlists" ("user_id", "title") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_1dddcfb81ad42bae8e6d10f473" ON "user_wantlists" ("user_id", "primary_artist") `, + ); + await queryRunner.query( + `CREATE INDEX "IDX_991c59c010ecbd15562a70229f" ON "user_wantlists" ("user_id", "date_added") `, + ); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "public"."IDX_991c59c010ecbd15562a70229f"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_1dddcfb81ad42bae8e6d10f473"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_568cf08059102d1e38543df9a9"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_8215b482f7ae75c712f1e2ac19"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_add9afabf4ee1595400208b46f"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_f497fb00f03e11ec8052ddee27"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_e1086300ea6f5981cbae2814fd"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_6783bb510bad97bf415eff392a"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_73d8426a1c155f7fc969148c6b"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_ec278e8abcbf006515a277f1ff"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_eda1661a006a8303c050552085"`, + ); + await queryRunner.query( + `DROP INDEX "public"."IDX_87dd10b9eb5b63b60a14ed17a4"`, + ); + await queryRunner.query( + `ALTER TABLE "user_wantlists" DROP COLUMN "vinyl_color"`, + ); + await queryRunner.query( + `ALTER TABLE "user_wantlists" DROP COLUMN "primary_format"`, + ); + await queryRunner.query( + `ALTER TABLE "user_wantlists" DROP COLUMN "primary_genre"`, + ); + await queryRunner.query(`ALTER TABLE "user_wantlists" DROP COLUMN "year"`); + await queryRunner.query( + `ALTER TABLE "user_wantlists" DROP COLUMN "all_artists"`, + ); + await queryRunner.query( + `ALTER TABLE "user_wantlists" DROP COLUMN "primary_artist"`, + ); + await queryRunner.query(`ALTER TABLE "user_wantlists" DROP COLUMN "title"`); + await queryRunner.query( + `ALTER TABLE "user_collections" DROP COLUMN "vinyl_color"`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" DROP COLUMN "primary_format"`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" DROP COLUMN "primary_genre"`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" DROP COLUMN "year"`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" DROP COLUMN "all_artists"`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" DROP COLUMN "primary_artist"`, + ); + await queryRunner.query( + `ALTER TABLE "user_collections" DROP COLUMN "title"`, + ); + await queryRunner.query( + `ALTER TABLE "releases" DROP COLUMN "record_label"`, + ); + await queryRunner.query( + `ALTER TABLE "releases" DROP COLUMN "catalog_number"`, + ); + await queryRunner.query(`ALTER TABLE "releases" DROP COLUMN "vinyl_color"`); + await queryRunner.query( + `ALTER TABLE "releases" DROP COLUMN "primary_format"`, + ); + await queryRunner.query( + `ALTER TABLE "releases" DROP COLUMN "primary_style"`, + ); + await queryRunner.query( + `ALTER TABLE "releases" DROP COLUMN "primary_genre"`, + ); + await queryRunner.query(`ALTER TABLE "releases" DROP COLUMN "all_artists"`); + await queryRunner.query( + `ALTER TABLE "releases" DROP COLUMN "primary_artist"`, + ); + } } diff --git a/src/discogs/discogs-api.service.ts b/src/discogs/discogs-api.service.ts index c279ecc..e573782 100644 --- a/src/discogs/discogs-api.service.ts +++ b/src/discogs/discogs-api.service.ts @@ -9,6 +9,7 @@ import { DiscogsRelease, } from './types/discogs.types'; import { DiscogsConfig } from './discogs.config'; +import { SearchReleasesResponse } from './dto/search-releases.dto'; @Injectable() export class DiscogsApiService { @@ -173,4 +174,107 @@ export class DiscogsApiService { this.logger.log(`Fetched complete wantlist: ${allWants.length} wants`); return allWants; } + + async searchReleases( + query: string, + page: number = 1, + perPage: number = 50, + ): Promise { + try { + const url = `${this.discogsConfig.baseUrl}/database/search`; + const params = { + q: query, + type: 'release', + page, + per_page: perPage, + }; + + this.logger.debug(`Searching releases with query: ${query}`); + + const response = await firstValueFrom( + this.httpService.get(url, { + headers: this.getRequestHeaders(), + params, + }), + ); + + this.logger.log( + `Search returned ${response.data.results.length} results for query: ${query}`, + ); + return response.data; + } catch (error) { + this.logger.error('Error searching releases on Discogs:', error); + + if (error.response?.status === 404) { + return { + results: [], + pagination: { + page, + pages: 0, + per_page: perPage, + items: 0, + }, + }; + } + + if (error.response?.status) { + throw new HttpException( + `Discogs API error: ${error.response.status}`, + error.response.status, + ); + } + + throw new HttpException( + 'Failed to search releases', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } + + async addToFolder( + releaseId: number, + folderId: number = 8797697, + ): Promise<{ instance_id: number }> { + try { + const url = `${this.discogsConfig.baseUrl}/users/${this.discogsConfig.username}/collection/folders/${folderId}/releases/${releaseId}`; + + this.logger.debug(`Adding release ${releaseId} to folder ${folderId}`); + + const response = await firstValueFrom( + this.httpService.post<{ instance_id: number }>( + url, + {}, + { + headers: this.getRequestHeaders(), + }, + ), + ); + + this.logger.log( + `Successfully added release ${releaseId} to folder ${folderId}`, + ); + return response.data; + } catch (error) { + this.logger.error('Error adding release to folder:', error); + + if (error.response?.status === 403) { + throw new HttpException( + 'Release already exists in folder', + HttpStatus.CONFLICT, + ); + } + + if (error.response?.status) { + throw new HttpException( + `Discogs API error: ${error.response.status}`, + error.response.status, + ); + } + + throw new HttpException( + 'Failed to add release to folder', + HttpStatus.INTERNAL_SERVER_ERROR, + ); + } + } } diff --git a/src/discogs/discogs.controller.ts b/src/discogs/discogs.controller.ts index b9f1b5f..f8fb838 100644 --- a/src/discogs/discogs.controller.ts +++ b/src/discogs/discogs.controller.ts @@ -4,6 +4,7 @@ import { Post, Param, Query, + Body, Logger, UseGuards, } from '@nestjs/common'; @@ -13,11 +14,20 @@ import { ApiParam, ApiQuery, ApiSecurity, + ApiResponse, } from '@nestjs/swagger'; import { DiscogsApiService } from './discogs-api.service'; import { DiscogsSyncService } from './discogs-sync.service'; import { DiscogsQueryParams } from './types/discogs.types'; import { ApiKeyGuard } from '../common/guards/api-key.guard'; +import { + SearchReleasesDto, + SearchReleasesResponse, +} from './dto/search-releases.dto'; +import { + SuggestReleaseDto, + SuggestReleaseResponse, +} from './dto/suggest-release.dto'; @ApiTags('discogs') @ApiSecurity('api-key') @@ -197,4 +207,66 @@ export class DiscogsController { }; } } + + @Get('search') + @ApiOperation({ + summary: 'Search Discogs releases', + description: 'Search for releases on Discogs by query string', + }) + @ApiResponse({ + status: 200, + description: 'Search results', + type: SearchReleasesResponse, + }) + @ApiResponse({ + status: 400, + description: 'Invalid query parameters', + }) + async searchReleases(@Query() searchDto: SearchReleasesDto) { + this.logger.log(`Searching releases with query: ${searchDto.query}`); + + return this.discogsApi.searchReleases( + searchDto.query, + searchDto.page, + searchDto.per_page, + ); + } + + @Post('suggest') + @ApiOperation({ + summary: 'Suggest a release', + description: 'Add a release to the suggestion folder (folder ID: 8797697)', + }) + @ApiResponse({ + status: 201, + description: 'Release successfully suggested', + type: SuggestReleaseResponse, + }) + @ApiResponse({ + status: 409, + description: 'Release already exists in suggestion folder', + }) + async suggestRelease( + @Body() suggestDto: SuggestReleaseDto, + ): Promise { + this.logger.log(`Suggesting release: ${suggestDto.releaseId}`); + + try { + const result = await this.discogsApi.addToFolder(suggestDto.releaseId); + + return { + success: true, + message: `Release ${suggestDto.releaseId} successfully added to suggestions`, + instance_id: result.instance_id, + }; + } catch (error) { + if (error.status === 409) { + return { + success: false, + message: 'Release already exists in suggestion folder', + }; + } + throw error; + } + } } diff --git a/src/discogs/dto/search-releases.dto.ts b/src/discogs/dto/search-releases.dto.ts new file mode 100644 index 0000000..3a02426 --- /dev/null +++ b/src/discogs/dto/search-releases.dto.ts @@ -0,0 +1,64 @@ +import { IsString, IsOptional, IsInt, Min, Max } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; + +export class SearchReleasesDto { + @ApiProperty({ description: 'Search query' }) + @IsString() + query: string; + + @ApiPropertyOptional({ description: 'Page number', default: 1 }) + @IsOptional() + @IsInt() + @Min(1) + @Transform(({ value }) => parseInt(value, 10)) + page?: number = 1; + + @ApiPropertyOptional({ description: 'Items per page', default: 50 }) + @IsOptional() + @IsInt() + @Min(1) + @Max(100) + @Transform(({ value }) => parseInt(value, 10)) + per_page?: number = 50; +} + +export class SearchReleaseResult { + @ApiProperty({ description: 'Release ID' }) + id: number; + + @ApiProperty({ description: 'Release title' }) + title: string; + + @ApiProperty({ description: 'Artists' }) + artist: string; + + @ApiProperty({ description: 'Release year' }) + year?: number; + + @ApiProperty({ description: 'Thumbnail image URL' }) + thumb?: string; + + @ApiProperty({ description: 'Cover image URL' }) + cover_image?: string; + + @ApiProperty({ description: 'Release format' }) + format?: string[]; + + @ApiProperty({ description: 'Resource URL' }) + resource_url: string; +} + +export class SearchReleasesResponse { + @ApiProperty({ type: [SearchReleaseResult] }) + results: SearchReleaseResult[]; + + @ApiProperty() + pagination: { + page: number; + pages: number; + per_page: number; + items: number; + }; +} + diff --git a/src/discogs/dto/suggest-release.dto.ts b/src/discogs/dto/suggest-release.dto.ts new file mode 100644 index 0000000..2efd25f --- /dev/null +++ b/src/discogs/dto/suggest-release.dto.ts @@ -0,0 +1,25 @@ +import { IsInt, IsOptional, IsString } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; + +export class SuggestReleaseDto { + @ApiProperty({ description: 'Discogs release ID to suggest' }) + @IsInt() + releaseId: number; + + @ApiPropertyOptional({ description: 'Optional notes for the suggestion' }) + @IsOptional() + @IsString() + notes?: string; +} + +export class SuggestReleaseResponse { + @ApiProperty({ description: 'Success status' }) + success: boolean; + + @ApiProperty({ description: 'Response message' }) + message: string; + + @ApiPropertyOptional({ description: 'Instance ID if successfully added' }) + instance_id?: number; +} + diff --git a/src/discogs/tests/discogs-api.service.spec.ts b/src/discogs/tests/discogs-api.service.spec.ts index fcc09e1..67c6bc2 100644 --- a/src/discogs/tests/discogs-api.service.spec.ts +++ b/src/discogs/tests/discogs-api.service.spec.ts @@ -25,6 +25,7 @@ describe('DiscogsApiService', () => { const mockHttpService = { get: jest.fn(), + post: jest.fn(), }; const mockBasicInformation: BasicInformation = { @@ -590,4 +591,254 @@ describe('DiscogsApiService', () => { ); }); }); + + describe('searchReleases', () => { + const mockSearchResponse = { + results: [ + { + id: 12345, + title: 'Test Album', + artist: 'Test Artist', + year: 2023, + thumb: 'https://example.com/thumb.jpg', + cover_image: 'https://example.com/cover.jpg', + format: ['CD', 'Album'], + resource_url: 'https://api.discogs.com/releases/12345', + }, + ], + pagination: { + page: 1, + pages: 5, + per_page: 50, + items: 250, + }, + }; + + it('should search releases successfully', async () => { + mockHttpService.get.mockReturnValue(of({ data: mockSearchResponse })); + + const result = await service.searchReleases('Pink Floyd'); + + expect(result).toEqual(mockSearchResponse); + expect(mockHttpService.get).toHaveBeenCalledWith( + 'https://api.discogs.com/database/search', + { + headers: { + Authorization: 'Discogs token=test-token-123', + 'User-Agent': 'NestJSDiscogsService/1.0', + }, + params: { + q: 'Pink Floyd', + type: 'release', + page: 1, + per_page: 50, + }, + }, + ); + }); + + it('should search with custom pagination', async () => { + mockHttpService.get.mockReturnValue(of({ data: mockSearchResponse })); + + await service.searchReleases('Beatles', 2, 25); + + expect(mockHttpService.get).toHaveBeenCalledWith( + 'https://api.discogs.com/database/search', + { + headers: { + Authorization: 'Discogs token=test-token-123', + 'User-Agent': 'NestJSDiscogsService/1.0', + }, + params: { + q: 'Beatles', + type: 'release', + page: 2, + per_page: 25, + }, + }, + ); + }); + + it('should return empty results on 404', async () => { + const error = { + response: { + status: 404, + }, + }; + mockHttpService.get.mockReturnValue(throwError(() => error)); + + const result = await service.searchReleases('NonexistentAlbum'); + + expect(result).toEqual({ + results: [], + pagination: { + page: 1, + pages: 0, + per_page: 50, + items: 0, + }, + }); + }); + + it('should handle API errors', async () => { + const error = { + response: { + status: 401, + }, + }; + mockHttpService.get.mockReturnValue(throwError(() => error)); + + await expect(service.searchReleases('Test')).rejects.toThrow( + new HttpException('Discogs API error: 401', 401), + ); + }); + + it('should handle network errors', async () => { + const error = new Error('Network error'); + mockHttpService.get.mockReturnValue(throwError(() => error)); + + await expect(service.searchReleases('Test')).rejects.toThrow( + new HttpException( + 'Failed to search releases', + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + }); + + it('should log search operations', async () => { + const debugSpy = jest.spyOn(service['logger'], 'debug'); + const logSpy = jest.spyOn(service['logger'], 'log'); + mockHttpService.get.mockReturnValue(of({ data: mockSearchResponse })); + + await service.searchReleases('Test Query'); + + expect(debugSpy).toHaveBeenCalledWith( + 'Searching releases with query: Test Query', + ); + expect(logSpy).toHaveBeenCalledWith( + 'Search returned 1 results for query: Test Query', + ); + }); + + it('should log errors on search failure', async () => { + const errorSpy = jest.spyOn(service['logger'], 'error'); + const error = new Error('Search failed'); + mockHttpService.get.mockReturnValue(throwError(() => error)); + + await expect(service.searchReleases('Test')).rejects.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Error searching releases on Discogs:', + error, + ); + }); + }); + + describe('addToFolder', () => { + const mockAddResponse = { instance_id: 999999 }; + + it('should add release to folder successfully', async () => { + mockHttpService.post.mockReturnValue(of({ data: mockAddResponse })); + + const result = await service.addToFolder(12345); + + expect(result).toEqual(mockAddResponse); + expect(mockHttpService.post).toHaveBeenCalledWith( + 'https://api.discogs.com/users/test-user/collection/folders/8797697/releases/12345', + {}, + { + headers: { + Authorization: 'Discogs token=test-token-123', + 'User-Agent': 'NestJSDiscogsService/1.0', + }, + }, + ); + }); + + it('should add release to custom folder', async () => { + mockHttpService.post.mockReturnValue(of({ data: mockAddResponse })); + + await service.addToFolder(67890, 123); + + expect(mockHttpService.post).toHaveBeenCalledWith( + 'https://api.discogs.com/users/test-user/collection/folders/123/releases/67890', + {}, + { + headers: { + Authorization: 'Discogs token=test-token-123', + 'User-Agent': 'NestJSDiscogsService/1.0', + }, + }, + ); + }); + + it('should handle conflict error (release already exists)', async () => { + const error = { + response: { + status: 403, + }, + }; + mockHttpService.post.mockReturnValue(throwError(() => error)); + + await expect(service.addToFolder(12345)).rejects.toThrow( + new HttpException( + 'Release already exists in folder', + HttpStatus.CONFLICT, + ), + ); + }); + + it('should handle other API errors', async () => { + const error = { + response: { + status: 401, + }, + }; + mockHttpService.post.mockReturnValue(throwError(() => error)); + + await expect(service.addToFolder(12345)).rejects.toThrow( + new HttpException('Discogs API error: 401', 401), + ); + }); + + it('should handle network errors', async () => { + const error = new Error('Network error'); + mockHttpService.post.mockReturnValue(throwError(() => error)); + + await expect(service.addToFolder(12345)).rejects.toThrow( + new HttpException( + 'Failed to add release to folder', + HttpStatus.INTERNAL_SERVER_ERROR, + ), + ); + }); + + it('should log add operations', async () => { + const debugSpy = jest.spyOn(service['logger'], 'debug'); + const logSpy = jest.spyOn(service['logger'], 'log'); + mockHttpService.post.mockReturnValue(of({ data: mockAddResponse })); + + await service.addToFolder(12345); + + expect(debugSpy).toHaveBeenCalledWith( + 'Adding release 12345 to folder 8797697', + ); + expect(logSpy).toHaveBeenCalledWith( + 'Successfully added release 12345 to folder 8797697', + ); + }); + + it('should log errors on add failure', async () => { + const errorSpy = jest.spyOn(service['logger'], 'error'); + const error = new Error('Add failed'); + mockHttpService.post.mockReturnValue(throwError(() => error)); + + await expect(service.addToFolder(12345)).rejects.toThrow(); + + expect(errorSpy).toHaveBeenCalledWith( + 'Error adding release to folder:', + error, + ); + }); + }); }); diff --git a/src/discogs/tests/discogs.controller.spec.ts b/src/discogs/tests/discogs.controller.spec.ts index 8076dab..867a825 100644 --- a/src/discogs/tests/discogs.controller.spec.ts +++ b/src/discogs/tests/discogs.controller.spec.ts @@ -3,7 +3,6 @@ import { DiscogsController } from '../discogs.controller'; import { DiscogsApiService } from '../discogs-api.service'; import { DiscogsSyncService } from '../discogs-sync.service'; import { ApiKeyGuard } from '../../common/guards/api-key.guard'; -import { DiscogsQueryParams } from '../types/discogs.types'; describe('DiscogsController', () => { let controller: DiscogsController; @@ -11,6 +10,8 @@ describe('DiscogsController', () => { const mockDiscogsApiService = { getCollection: jest.fn(), getWantlist: jest.fn(), + searchReleases: jest.fn(), + addToFolder: jest.fn(), }; const mockDiscogsSyncService = { @@ -488,6 +489,198 @@ describe('DiscogsController', () => { }); }); + describe('searchReleases', () => { + const mockSearchResponse = { + results: [ + { + id: 12345, + title: 'Test Album', + artist: 'Test Artist', + year: 2023, + thumb: 'https://example.com/thumb.jpg', + cover_image: 'https://example.com/cover.jpg', + format: ['CD', 'Album'], + resource_url: 'https://api.discogs.com/releases/12345', + }, + ], + pagination: { + page: 1, + pages: 5, + per_page: 50, + items: 250, + }, + }; + + it('should search releases with required query parameter', async () => { + mockDiscogsApiService.searchReleases.mockResolvedValue( + mockSearchResponse, + ); + + const searchDto = { query: 'Pink Floyd', page: 1, per_page: 50 }; + const result = await controller.searchReleases(searchDto); + + expect(result).toEqual(mockSearchResponse); + expect(mockDiscogsApiService.searchReleases).toHaveBeenCalledWith( + 'Pink Floyd', + 1, + 50, + ); + }); + + it('should search releases with custom pagination', async () => { + mockDiscogsApiService.searchReleases.mockResolvedValue( + mockSearchResponse, + ); + + const searchDto = { query: 'Beatles', page: 2, per_page: 25 }; + const result = await controller.searchReleases(searchDto); + + expect(result).toEqual(mockSearchResponse); + expect(mockDiscogsApiService.searchReleases).toHaveBeenCalledWith( + 'Beatles', + 2, + 25, + ); + }); + + it('should use default pagination when not provided', async () => { + mockDiscogsApiService.searchReleases.mockResolvedValue( + mockSearchResponse, + ); + + const searchDto = { query: 'Led Zeppelin' }; + const result = await controller.searchReleases(searchDto); + + expect(result).toEqual(mockSearchResponse); + expect(mockDiscogsApiService.searchReleases).toHaveBeenCalledWith( + 'Led Zeppelin', + undefined, + undefined, + ); + }); + + it('should return empty results when no matches found', async () => { + const emptyResponse = { + results: [], + pagination: { + page: 1, + pages: 0, + per_page: 50, + items: 0, + }, + }; + mockDiscogsApiService.searchReleases.mockResolvedValue(emptyResponse); + + const searchDto = { query: 'NonexistentAlbum123' }; + const result = await controller.searchReleases(searchDto); + + expect(result).toEqual(emptyResponse); + }); + + it('should log search request', async () => { + const logSpy = jest.spyOn(controller['logger'], 'log'); + mockDiscogsApiService.searchReleases.mockResolvedValue( + mockSearchResponse, + ); + + const searchDto = { query: 'Test Query' }; + await controller.searchReleases(searchDto); + + expect(logSpy).toHaveBeenCalledWith( + 'Searching releases with query: Test Query', + ); + }); + + it('should handle service errors', async () => { + const error = new Error('Search API error'); + mockDiscogsApiService.searchReleases.mockRejectedValue(error); + + const searchDto = { query: 'Error Test' }; + await expect(controller.searchReleases(searchDto)).rejects.toThrow(error); + }); + }); + + describe('suggestRelease', () => { + it('should successfully suggest a release', async () => { + const mockAddResult = { instance_id: 999999 }; + mockDiscogsApiService.addToFolder.mockResolvedValue(mockAddResult); + + const suggestDto = { releaseId: 12345 }; + const result = await controller.suggestRelease(suggestDto); + + expect(result).toEqual({ + success: true, + message: 'Release 12345 successfully added to suggestions', + instance_id: 999999, + }); + expect(mockDiscogsApiService.addToFolder).toHaveBeenCalledWith(12345); + }); + + it('should handle already existing release', async () => { + const conflictError = new Error('Release already exists'); + (conflictError as any).status = 409; + mockDiscogsApiService.addToFolder.mockRejectedValue(conflictError); + + const suggestDto = { releaseId: 12345 }; + const result = await controller.suggestRelease(suggestDto); + + expect(result).toEqual({ + success: false, + message: 'Release already exists in suggestion folder', + }); + }); + + it('should suggest release with notes (for future implementation)', async () => { + const mockAddResult = { instance_id: 888888 }; + mockDiscogsApiService.addToFolder.mockResolvedValue(mockAddResult); + + const suggestDto = { + releaseId: 67890, + notes: 'Great album recommendation!', + }; + const result = await controller.suggestRelease(suggestDto); + + expect(result).toEqual({ + success: true, + message: 'Release 67890 successfully added to suggestions', + instance_id: 888888, + }); + expect(mockDiscogsApiService.addToFolder).toHaveBeenCalledWith(67890); + }); + + it('should log suggest request', async () => { + const logSpy = jest.spyOn(controller['logger'], 'log'); + const mockAddResult = { instance_id: 777777 }; + mockDiscogsApiService.addToFolder.mockResolvedValue(mockAddResult); + + const suggestDto = { releaseId: 11111 }; + await controller.suggestRelease(suggestDto); + + expect(logSpy).toHaveBeenCalledWith('Suggesting release: 11111'); + }); + + it('should propagate non-409 errors', async () => { + const error = new Error('API Error'); + (error as any).status = 500; + mockDiscogsApiService.addToFolder.mockRejectedValue(error); + + const suggestDto = { releaseId: 99999 }; + await expect(controller.suggestRelease(suggestDto)).rejects.toThrow( + error, + ); + }); + + it('should handle unexpected errors', async () => { + const error = new Error('Network error'); + mockDiscogsApiService.addToFolder.mockRejectedValue(error); + + const suggestDto = { releaseId: 55555 }; + await expect(controller.suggestRelease(suggestDto)).rejects.toThrow( + error, + ); + }); + }); + describe('ApiKeyGuard', () => { it('should have ApiKeyGuard applied to controller', () => { const guards = Reflect.getMetadata('__guards__', DiscogsController);