diff --git a/app/kits/exceptions/cannot_resolve_handle_exception.ts b/app/kits/exceptions/cannot_resolve_handle_exception.ts new file mode 100644 index 0000000..430eb4d --- /dev/null +++ b/app/kits/exceptions/cannot_resolve_handle_exception.ts @@ -0,0 +1,21 @@ +import { Exception } from '@adonisjs/core/exceptions' +import type { HttpContext } from '@adonisjs/core/http' + +export default class CannotResolveHandleException extends Exception { + static status = 400 + static code = 'E_CANNOT_RESOLVE_HANDLE' + + constructor( + message = 'Unable to resolve your Bluesky/ATProto handle. Please try again in a moment.' + ) { + super(message, { + status: CannotResolveHandleException.status, + code: CannotResolveHandleException.code, + }) + } + + async handle(_error: this, ctx: HttpContext) { + ctx.session.flash('error', this.message) + return ctx.response.redirect().back() + } +} diff --git a/app/kits/services/kits_service.ts b/app/kits/services/kits_service.ts index 15be529..6960bad 100644 --- a/app/kits/services/kits_service.ts +++ b/app/kits/services/kits_service.ts @@ -6,13 +6,18 @@ import { DateTime } from 'luxon' import gunplaKit from '#lexicons/build/gunpla/kit' import Kit from '#kits/models/kit' import KitRecordTransformer from '#kits/transformers/kit_record_transformer' +import { IdentityService } from '#profile/services/identity_service' +import CannotResolveHandleException from '#kits/exceptions/cannot_resolve_handle_exception' import type { Main } from '#lexicons/build/gunpla/kit.defs' import type { Infer } from '@vinejs/vine/types' import type { createKitValidator, updateKitValidator } from '#kits/validators/kit' @inject() export class KitsService { - constructor(protected user: AtprotoUser) {} + constructor( + protected user: AtprotoUser, + protected identityService: IdentityService + ) {} async listForUser() { const { records } = await this.user.client.list(gunplaKit) @@ -27,6 +32,12 @@ export class KitsService { } async create(data: Infer) { + const identity = await this.identityService.resolve(this.user.did) + if (!identity?.handle) { + throw new CannotResolveHandleException() + } + const handle = identity.handle + const filesToUpload: Array<{ tmpPath: string; type?: string; subtype?: string }> = [] if (data.photo?.tmpPath) { filesToUpload.push({ @@ -76,9 +87,6 @@ export class KitsService { createdAt: l.currentDatetimeString(), }) - const existingKit = await Kit.query().where('did', this.user.did).whereNotNull('handle').first() - const handle = existingKit?.handle - await Kit.updateOrCreate( { uri: record.uri }, { @@ -97,6 +105,8 @@ export class KitsService { } ) + await Kit.query().where('did', this.user.did).update({ handle }) + return record } diff --git a/tests/functional/kits.spec.ts b/tests/functional/kits.spec.ts index 980c644..8d89b73 100644 --- a/tests/functional/kits.spec.ts +++ b/tests/functional/kits.spec.ts @@ -2,16 +2,12 @@ import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' import type { HttpContext } from '@adonisjs/core/http' import type { NextFn } from '@adonisjs/core/types/http' -import { AtprotoUser } from '@thisismissem/adonisjs-atproto-oauth' -import { asAtUriString } from '@atproto/syntax' -import { l } from '@atproto/lex' import { DateTime } from 'luxon' import AuthMiddleware from '#middleware/auth_middleware' import { KitsService } from '#kits/services/kits_service' -import KitRecordTransformer from '#kits/transformers/kit_record_transformer' import Kit from '#kits/models/kit' -import type { Infer } from '@vinejs/vine/types' -import type { createKitValidator, updateKitValidator } from '#kits/validators/kit' +import { FakeKitsService } from '../helpers/fake_kits_service.js' +import { FakeIdentityService } from '../helpers/fake_identity_service.js' class FakeAuthMiddleware extends AuthMiddleware { override async handle(_ctx: HttpContext, next: NextFn) { @@ -19,100 +15,6 @@ class FakeAuthMiddleware extends AuthMiddleware { } } -class FakeKitsService extends KitsService { - constructor() { - super(Object.create(AtprotoUser.prototype) as AtprotoUser) - } - - override async listForUser() { - return KitRecordTransformer.transform([]) - } - - override async getByRkey(rkey: string) { - if (rkey === 'not-found') { - throw new Error('Not found') - } - return { - uri: asAtUriString(`at://did:plc:test/build.gunpla.kit/${rkey}`), - cid: 'bafytestcid', - value: { - $type: 'build.gunpla.kit' as const, - name: 'HG Aerial', - grade: 'HG', - scale: '1/144', - status: 'done' as const, - createdAt: l.currentDatetimeString(), - }, - } - } - - override async create(data: Infer) { - const photoCids: string[] = [] - if (data.photo) photoCids.push('bafyfakephotocid') - if (Array.isArray(data.photos)) { - data.photos.forEach((_, i) => photoCids.push(`bafyfakephotocid${i}`)) - } - const photoCid = photoCids[0] ?? null - - await Kit.create({ - uri: 'at://did:plc:test/build.gunpla.kit/new-aerial', - did: 'did:plc:test', - name: data.name, - grade: data.grade ?? null, - scale: data.scale ?? null, - manufacturer: data.manufacturer ?? null, - acquiredAt: data.acquiredAt ? DateTime.fromISO(data.acquiredAt) : null, - status: data.status, - photoCid, - photos: photoCids.length > 0 ? photoCids : null, - indexedAt: DateTime.now(), - }) - return { - uri: asAtUriString('at://did:plc:test/build.gunpla.kit/new-aerial'), - cid: 'bafytestcid', - } - } - - override async update(rkey: string, data: Infer) { - const photoCids: string[] = [] - if (data.existingPhotos) { - photoCids.push(...data.existingPhotos) - } - if (data.photo) photoCids.push('bafynewphoto') - if (Array.isArray(data.photos)) { - data.photos.forEach((_, i) => photoCids.push(`bafynewphoto${i}`)) - } - - await Kit.query() - .where('uri', 'like', `%/${rkey}`) - .update({ - name: data.name, - grade: data.grade ?? null, - scale: data.scale ?? null, - manufacturer: data.manufacturer ?? null, - acquiredAt: data.acquiredAt ? DateTime.fromISO(data.acquiredAt) : null, - status: data.status, - photoCid: photoCids[0] ?? null, - photos: photoCids.length > 0 ? JSON.stringify(photoCids) : null, - }) - return { - uri: `at://did:plc:test/build.gunpla.kit/${rkey}`, - value: { - $type: 'build.gunpla.kit' as const, - name: data.name, - grade: data.grade ?? undefined, - scale: data.scale ?? undefined, - status: data.status, - createdAt: l.currentDatetimeString(), - }, - } - } - - override async delete(rkey: string) { - await Kit.query().where('uri', 'like', `%/${rkey}`).delete() - } -} - test.group('Kits Management', (group) => { group.each.setup(() => testUtils.db().truncate()) @@ -263,10 +165,10 @@ test.group('Kits Management', (group) => { const response = await client .visit('kits.store') .withCsrfToken() - .fields({ + .form({ name: 'Invalid Date Kit', status: 'backlog', - acquiredAt: 'not-a-valid-date', + acquiredAt: 'invalid-date', }) .redirects(0) @@ -369,4 +271,65 @@ test.group('Kits Management', (group) => { await db.assertModelMissing(kit) }) + + test('kit creation aborts and flashes error when handle cannot be resolved', async ({ + client, + db, + swap, + }) => { + swap(AuthMiddleware, new FakeAuthMiddleware()) + swap(KitsService, new FakeKitsService(new FakeIdentityService({ defaultHandle: null }))) + + const response = await client + .visit('kits.store') + .withCsrfToken() + .header('referer', '/kits/new') + .form({ + name: 'HGUC RX-78-2', + status: 'backlog', + }) + .redirects(0) + + response.assertStatus(302) + response.assertHeader('location', '/kits/new') + response.assertFlashMessage( + 'error', + 'Unable to resolve your Bluesky/ATProto handle. Please try again in a moment.' + ) + + await db.assertEmpty('kits') + }) + + test('first kit creation succeeds and stores resolved handle in database', async ({ + client, + db, + swap, + }) => { + swap(AuthMiddleware, new FakeAuthMiddleware()) + swap( + KitsService, + new FakeKitsService(new FakeIdentityService({ defaultHandle: 'builder.bsky.social' })) + ) + + const response = await client + .visit('kits.store') + .withCsrfToken() + .form({ + name: 'HG Aerial', + status: 'done', + grade: 'HG', + scale: '1/144', + }) + .redirects(0) + + response.assertStatus(302) + response.assertHeader('location', '/shelf') + response.assertFlashMessage('success', 'Kit "HG Aerial" added to your shelf') + + await db.assertHas('kits', { + name: 'HG Aerial', + handle: 'builder.bsky.social', + did: 'did:plc:test', + }) + }) }) diff --git a/tests/helpers/fake_atproto_user.ts b/tests/helpers/fake_atproto_user.ts new file mode 100644 index 0000000..1a8e612 --- /dev/null +++ b/tests/helpers/fake_atproto_user.ts @@ -0,0 +1,86 @@ +import { AtprotoUser } from '@thisismissem/adonisjs-atproto-oauth' +import { + Client, + type CreateOptions, + type CreateOutput, + type RecordSchema, + type Main, + type Infer, + asAtUriString, +} from '@atproto/lex' + +export interface FakeAtprotoUserOptions { + did?: `did:plc:${string}` + createHandler?: () => Promise + uploadBlobHandler?: () => Promise +} + +export class FakeClient extends Client { + #createHandler?: () => Promise + #uploadBlobHandler?: () => Promise + + constructor(options: FakeAtprotoUserOptions = {}) { + const did = options.did ?? 'did:plc:test' + super({ + did, + fetchHandler: async () => { + const body = JSON.stringify({ + blob: { + $type: 'blob', + ref: { $link: 'bafkreiadsbmmn4waznesyuz3bjgrj33xzqhxrk6mz3ksq7meugrachh3qe' }, + mimeType: 'image/jpeg', + size: 1234, + }, + }) + return new Response(body, { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }) + }, + }) + this.#createHandler = options.createHandler + this.#uploadBlobHandler = options.uploadBlobHandler + } + + override async create( + _ns: Main, + _input: Omit, '$type'>, + _options?: CreateOptions + ): Promise { + if (this.#createHandler) { + return this.#createHandler() + } + return { + uri: asAtUriString(`at://${this.assertDid}/build.gunpla.kit/fake`), + cid: 'bafkreiadsbmmn4waznesyuz3bjgrj33xzqhxrk6mz3ksq7meugrachh3qe', + } + } + + override async uploadBlob( + ...args: Parameters + ): ReturnType { + if (this.#uploadBlobHandler) { + await this.#uploadBlobHandler() + } + return super.uploadBlob(...args) + } +} + +export class FakeAtprotoUser extends AtprotoUser { + #fakeDid: `did:plc:${string}` + #fakeClient: Client + + constructor(options: FakeAtprotoUserOptions = {}) { + super(Object.create(null)) + this.#fakeDid = options.did ?? 'did:plc:test' + this.#fakeClient = new FakeClient(options) + } + + override get did(): `did:plc:${string}` { + return this.#fakeDid + } + + override get client(): Client { + return this.#fakeClient + } +} diff --git a/tests/helpers/fake_identity_service.ts b/tests/helpers/fake_identity_service.ts new file mode 100644 index 0000000..1270cff --- /dev/null +++ b/tests/helpers/fake_identity_service.ts @@ -0,0 +1,26 @@ +import { IdentityService, type ResolvedIdentity } from '#profile/services/identity_service' + +export interface FakeIdentityOptions { + resolver?: (identifier: string) => Promise | ResolvedIdentity | null + defaultHandle?: string | null +} + +export class FakeIdentityService extends IdentityService { + constructor(private options: FakeIdentityOptions = {}) { + super() + } + + override async resolve(identifier: string): Promise { + if (this.options.resolver) { + return this.options.resolver(identifier) + } + if (this.options.defaultHandle === null) { + return null + } + const handle = this.options.defaultHandle ?? 'builder.bsky.social' + return { + did: identifier.startsWith('did:') ? identifier : `did:plc:${identifier}`, + handle, + } + } +} diff --git a/tests/helpers/fake_kits_service.ts b/tests/helpers/fake_kits_service.ts new file mode 100644 index 0000000..af23a37 --- /dev/null +++ b/tests/helpers/fake_kits_service.ts @@ -0,0 +1,113 @@ +import { KitsService } from '#kits/services/kits_service' +import type { IdentityService } from '#profile/services/identity_service' +import { FakeIdentityService } from './fake_identity_service.js' +import { FakeAtprotoUser } from './fake_atproto_user.js' +import CannotResolveHandleException from '#kits/exceptions/cannot_resolve_handle_exception' +import KitRecordTransformer from '#kits/transformers/kit_record_transformer' +import Kit from '#kits/models/kit' +import { asAtUriString } from '@atproto/syntax' +import { l } from '@atproto/lex' +import { DateTime } from 'luxon' +import type { Infer } from '@vinejs/vine/types' +import type { createKitValidator, updateKitValidator } from '#kits/validators/kit' + +export class FakeKitsService extends KitsService { + constructor(identityService: IdentityService = new FakeIdentityService()) { + super(new FakeAtprotoUser(), identityService) + } + + override async listForUser() { + return KitRecordTransformer.transform([]) + } + + override async getByRkey(rkey: string) { + if (rkey === 'not-found') { + throw new Error('Not found') + } + return { + uri: asAtUriString(`at://did:plc:test/build.gunpla.kit/${rkey}`), + cid: 'bafytestcid', + value: { + $type: 'build.gunpla.kit' as const, + name: 'HG Aerial', + grade: 'HG', + scale: '1/144', + status: 'done' as const, + createdAt: l.currentDatetimeString(), + }, + } + } + + override async create(data: Infer) { + const identity = await this.identityService.resolve('did:plc:test') + if (!identity?.handle) { + throw new CannotResolveHandleException() + } + const handle = identity.handle + + const photoCids: string[] = [] + if (data.photo) photoCids.push('bafyfakephotocid') + if (Array.isArray(data.photos)) { + data.photos.forEach((_, i) => photoCids.push(`bafyfakephotocid${i}`)) + } + const photoCid = photoCids[0] ?? null + + await Kit.create({ + uri: 'at://did:plc:test/build.gunpla.kit/new-aerial', + did: 'did:plc:test', + handle, + name: data.name, + grade: data.grade ?? null, + scale: data.scale ?? null, + manufacturer: data.manufacturer ?? null, + acquiredAt: data.acquiredAt ? DateTime.fromISO(data.acquiredAt) : null, + status: data.status, + photoCid, + photos: photoCids.length > 0 ? photoCids : null, + indexedAt: DateTime.now(), + }) + return { + uri: asAtUriString('at://did:plc:test/build.gunpla.kit/new-aerial'), + cid: 'bafytestcid', + } + } + + override async update(rkey: string, data: Infer) { + const photoCids: string[] = [] + if (data.existingPhotos) { + photoCids.push(...data.existingPhotos) + } + if (data.photo) photoCids.push('bafynewphoto') + if (Array.isArray(data.photos)) { + data.photos.forEach((_, i) => photoCids.push(`bafynewphoto${i}`)) + } + + await Kit.query() + .where('uri', 'like', `%/${rkey}`) + .update({ + name: data.name, + grade: data.grade ?? null, + scale: data.scale ?? null, + manufacturer: data.manufacturer ?? null, + acquiredAt: data.acquiredAt ? DateTime.fromISO(data.acquiredAt) : null, + status: data.status, + photoCid: photoCids[0] ?? null, + photos: photoCids.length > 0 ? JSON.stringify(photoCids) : null, + }) + return { + uri: `at://did:plc:test/build.gunpla.kit/${rkey}`, + value: { + $type: 'build.gunpla.kit' as const, + name: data.name, + grade: data.grade ?? undefined, + scale: data.scale ?? undefined, + status: data.status, + createdAt: l.currentDatetimeString(), + }, + } + } + + override async delete(rkey: string) { + await Kit.query().where('uri', 'like', `%/${rkey}`).delete() + } +} diff --git a/tests/unit/kits_service.spec.ts b/tests/unit/kits_service.spec.ts new file mode 100644 index 0000000..a290d00 --- /dev/null +++ b/tests/unit/kits_service.spec.ts @@ -0,0 +1,103 @@ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import { asAtUriString } from '@atproto/syntax' +import { KitsService } from '#kits/services/kits_service' +import { KitFactory } from '#database/factories/kit_factory' +import CannotResolveHandleException from '#kits/exceptions/cannot_resolve_handle_exception' +import { FakeAtprotoUser } from '../helpers/fake_atproto_user.js' +import { FakeIdentityService } from '../helpers/fake_identity_service.js' + +test.group('KitsService', (group) => { + group.each.setup(() => testUtils.db().truncate()) + + test('resolves user handle on first kit creation and updates existing kit records', async ({ + db, + }) => { + const did = 'did:plc:builder123' + + await KitFactory.merge({ + did, + handle: 'old-handle.bsky.social', + }).create() + + const fakeIdentityService = new FakeIdentityService({ + resolver: (identifier) => { + if (identifier === did) { + return { did, handle: 'new-handle.bsky.social' } + } + return null + }, + }) + + const fakeUser = new FakeAtprotoUser({ + did, + createHandler: async () => ({ + uri: asAtUriString(`at://${did}/build.gunpla.kit/new-kit`), + cid: 'bafytestcid', + }), + }) + + const service = new KitsService(fakeUser, fakeIdentityService) + + await service.create({ + name: 'RG Zeta', + status: 'done', + }) + + await db.assertHas('kits', { + name: 'RG Zeta', + handle: 'new-handle.bsky.social', + }) + + await db.assertHas( + 'kits', + { + did, + handle: 'new-handle.bsky.social', + }, + 2 + ) + }) + + test('throws CannotResolveHandleException without calling remote PDS when handle resolution fails', async ({ + assert, + db, + }) => { + const did = 'did:plc:builder123' + + const fakeIdentityService = new FakeIdentityService({ defaultHandle: null }) + + let clientCreateCalled = false + let clientUploadBlobCalled = false + + const fakeUser = new FakeAtprotoUser({ + did, + createHandler: async () => { + clientCreateCalled = true + return { + uri: asAtUriString(`at://${did}/build.gunpla.kit/new-kit`), + cid: 'bafytestcid', + } + }, + uploadBlobHandler: async () => { + clientUploadBlobCalled = true + return { + body: { blob: {} }, + } + }, + }) + + const service = new KitsService(fakeUser, fakeIdentityService) + + await assert.rejects(async () => { + await service.create({ + name: 'MG Freedom', + status: 'backlog', + }) + }, CannotResolveHandleException) + + assert.isFalse(clientCreateCalled) + assert.isFalse(clientUploadBlobCalled) + await db.assertEmpty('kits') + }) +})