From 11ef782173e15b72c202a74db188bb88762fde43 Mon Sep 17 00:00:00 2001 From: Gerald Leban Date: Wed, 7 Oct 2026 11:48:32 +0200 Subject: [PATCH] feat(profile): centralize handles into profiles and remove handle from kits and build logs - Introduce profiles table to store handles, avatars, display names, and bios - Remove denormalized handle column from kits and build_logs tables - Update models, relationships, and transformers to resolve handles through Profile - Update Jetstream, repo sync, and kits services to sync with profiles table - Add test doubles in tests/helpers and update test suite --- .adonisjs/client/data.d.ts | 7 ++ app/auth/controllers/callback_controller.ts | 8 +- app/explore/services/explore_service.ts | 5 +- app/feed/services/feed_service.ts | 2 +- app/kits/models/build_log.ts | 7 +- app/kits/models/kit.ts | 11 +-- app/kits/services/kits_service.ts | 9 ++- app/kits/services/repo_sync_service.ts | 34 ++++---- .../transformers/build_log_transformer.ts | 1 - app/kits/transformers/kit_transformer.ts | 11 ++- app/profile/models/profile.ts | 41 ++++++++++ app/profile/services/profile_service.ts | 77 +++++++++++++++---- .../transformers/profile_transformer.ts | 8 ++ app/stats/services/stats_service.ts | 7 +- database/factories/build_log_factory.ts | 1 - database/factories/kit_factory.ts | 1 - database/factories/profile_factory.ts | 16 ++++ .../1791100471941_create_kits_table.ts | 1 - .../1791104183282_create_build_logs_table.ts | 1 - .../1791354921383_create_profiles_table.ts | 22 ++++++ database/schema.ts | 60 ++++++--------- services/jetstream_service.ts | 34 +++----- tests/functional/callback/handle.spec.ts | 37 +++++++++ tests/functional/explore/render.spec.ts | 47 ++++++++++- tests/functional/feed/render.spec.ts | 44 ++++++++++- tests/functional/kits.spec.ts | 8 +- tests/functional/profile.spec.ts | 37 +++++++-- .../helpers/fake_auth_middleware_with_user.ts | 5 +- tests/helpers/fake_kits_service.ts | 34 +++----- tests/helpers/fake_profile_service.ts | 25 ++++++ tests/helpers/fake_repo_sync_service.ts | 2 +- tests/helpers/testable_repo_sync_service.ts | 33 ++++++++ tests/unit/kits_service.spec.ts | 20 ++--- tests/unit/profile_service.spec.ts | 59 ++++++++++++++ tests/unit/repo_sync_service.spec.ts | 32 +++++++- 35 files changed, 574 insertions(+), 173 deletions(-) create mode 100644 app/profile/models/profile.ts create mode 100644 app/profile/transformers/profile_transformer.ts create mode 100644 database/factories/profile_factory.ts create mode 100644 database/migrations/1791354921383_create_profiles_table.ts create mode 100644 tests/helpers/fake_profile_service.ts create mode 100644 tests/helpers/testable_repo_sync_service.ts create mode 100644 tests/unit/profile_service.spec.ts diff --git a/.adonisjs/client/data.d.ts b/.adonisjs/client/data.d.ts index 7527888..35aaacc 100644 --- a/.adonisjs/client/data.d.ts +++ b/.adonisjs/client/data.d.ts @@ -9,6 +9,7 @@ import type { InferSharedProps, InferFlashData } from '@adonisjs/inertia/types' import type KitsBuildLogTransformer from '#app/kits/transformers/build_log_transformer' import type KitsKitRecordTransformer from '#app/kits/transformers/kit_record_transformer' import type KitsKitTransformer from '#app/kits/transformers/kit_transformer' +import type ProfileProfileTransformer from '#app/profile/transformers/profile_transformer' import type InertiaMiddleware from '#core/middleware/inertia_middleware' export namespace Data { @@ -26,6 +27,12 @@ export namespace Data { export type Variants = InferVariants } } + export namespace Profile { + export type Profile = InferData + export namespace Profile { + export type Variants = InferVariants + } + } export type SharedProps = InferSharedProps export type FlashMessages = InferFlashData } diff --git a/app/auth/controllers/callback_controller.ts b/app/auth/controllers/callback_controller.ts index 9342e48..a5261eb 100644 --- a/app/auth/controllers/callback_controller.ts +++ b/app/auth/controllers/callback_controller.ts @@ -2,13 +2,15 @@ import { inject } from '@adonisjs/core' import type { HttpContext } from '@adonisjs/core/http' import { AuthService } from '#auth/services/auth_service' import { RepoSyncService } from '#kits/services/repo_sync_service' +import { ProfileService } from '#profile/services/profile_service' import Kit from '#kits/models/kit' @inject() export default class CallbackController { constructor( private authService: AuthService, - private repoSyncService: RepoSyncService + private repoSyncService: RepoSyncService, + private profileService: ProfileService ) {} async handle({ response, logger }: HttpContext) { @@ -16,6 +18,10 @@ export default class CallbackController { const session = await this.authService.handleCallback() const did = session.user.did + await this.profileService.syncProfile(did).catch((err) => { + logger.warn({ err, did }, 'Profile sync failed on login') + }) + const isNewUser = !(await Kit.hasKitsFor(did)) if (isNewUser) { diff --git a/app/explore/services/explore_service.ts b/app/explore/services/explore_service.ts index 5ffbc18..a7a70a2 100644 --- a/app/explore/services/explore_service.ts +++ b/app/explore/services/explore_service.ts @@ -11,6 +11,7 @@ export class ExploreService { let query = Kit.query() .where('status', 'done') + .preload('profile') .preload('buildLogs') .orderBy('indexed_at', 'desc') @@ -19,7 +20,9 @@ export class ExploreService { query = query.where((builder) => { builder .whereRaw('LOWER(name) LIKE ?', [`%${term}%`]) - .orWhereRaw('LOWER(handle) LIKE ?', [`%${term}%`]) + .orWhereHas('profile', (profileQuery) => { + profileQuery.whereRaw('LOWER(handle) LIKE ?', [`%${term}%`]) + }) .orWhereRaw('LOWER(grade) LIKE ?', [`%${term}%`]) }) } diff --git a/app/feed/services/feed_service.ts b/app/feed/services/feed_service.ts index 985898e..d54adf0 100644 --- a/app/feed/services/feed_service.ts +++ b/app/feed/services/feed_service.ts @@ -3,7 +3,7 @@ import KitTransformer from '#kits/transformers/kit_transformer' export class FeedService { async getRecentKits(limit = 50) { - const kits = await Kit.query().orderBy('indexed_at', 'desc').limit(limit) + const kits = await Kit.query().preload('profile').orderBy('indexed_at', 'desc').limit(limit) return KitTransformer.transform(kits) } } diff --git a/app/kits/models/build_log.ts b/app/kits/models/build_log.ts index a02235b..24c482f 100644 --- a/app/kits/models/build_log.ts +++ b/app/kits/models/build_log.ts @@ -2,6 +2,7 @@ import { BaseModel, belongsTo, column } from '@adonisjs/lucid/orm' import type { BelongsTo } from '@adonisjs/lucid/types/relations' import { DateTime } from 'luxon' import Kit from './kit.js' +import Profile from '#profile/models/profile' export default class BuildLog extends BaseModel { static table = 'build_logs' @@ -18,15 +19,15 @@ export default class BuildLog extends BaseModel { @column() declare did: string - @column() - declare handle: string | null - @column() declare kitUri: string @belongsTo(() => Kit, { foreignKey: 'kitUri', localKey: 'uri' }) declare kit: BelongsTo + @belongsTo(() => Profile, { foreignKey: 'did', localKey: 'did' }) + declare profile: BelongsTo + @column() declare stage: string | null diff --git a/app/kits/models/kit.ts b/app/kits/models/kit.ts index f7dd3d6..db515b1 100644 --- a/app/kits/models/kit.ts +++ b/app/kits/models/kit.ts @@ -1,7 +1,8 @@ -import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm' -import type { HasMany } from '@adonisjs/lucid/types/relations' +import { BaseModel, belongsTo, column, hasMany } from '@adonisjs/lucid/orm' +import type { BelongsTo, HasMany } from '@adonisjs/lucid/types/relations' import { DateTime } from 'luxon' import BuildLog from './build_log.js' +import Profile from '#profile/models/profile' export default class Kit extends BaseModel { static table = 'kits' @@ -18,9 +19,6 @@ export default class Kit extends BaseModel { @column() declare did: string - @column() - declare handle: string | null - @column() declare name: string @@ -69,6 +67,9 @@ export default class Kit extends BaseModel { @column.dateTime({ autoCreate: true, autoUpdate: true }) declare updatedAt: DateTime + @belongsTo(() => Profile, { foreignKey: 'did', localKey: 'did' }) + declare profile: BelongsTo + @hasMany(() => BuildLog, { localKey: 'uri', foreignKey: 'kitUri' }) declare buildLogs: HasMany diff --git a/app/kits/services/kits_service.ts b/app/kits/services/kits_service.ts index 6960bad..88158f4 100644 --- a/app/kits/services/kits_service.ts +++ b/app/kits/services/kits_service.ts @@ -5,6 +5,7 @@ import { getBlobCidString, isBlobRef, l } from '@atproto/lex' import { DateTime } from 'luxon' import gunplaKit from '#lexicons/build/gunpla/kit' import Kit from '#kits/models/kit' +import Profile from '#profile/models/profile' import KitRecordTransformer from '#kits/transformers/kit_record_transformer' import { IdentityService } from '#profile/services/identity_service' import CannotResolveHandleException from '#kits/exceptions/cannot_resolve_handle_exception' @@ -38,6 +39,11 @@ export class KitsService { } const handle = identity.handle + await Profile.firstOrCreate( + { did: this.user.did }, + { did: this.user.did, handle, indexedAt: DateTime.now() } + ) + const filesToUpload: Array<{ tmpPath: string; type?: string; subtype?: string }> = [] if (data.photo?.tmpPath) { filesToUpload.push({ @@ -91,7 +97,6 @@ export class KitsService { { uri: record.uri }, { did: this.user.did, - handle, name: data.name, grade: data.grade, scale: data.scale, @@ -105,8 +110,6 @@ export class KitsService { } ) - await Kit.query().where('did', this.user.did).update({ handle }) - return record } diff --git a/app/kits/services/repo_sync_service.ts b/app/kits/services/repo_sync_service.ts index 5b52c97..54850eb 100644 --- a/app/kits/services/repo_sync_service.ts +++ b/app/kits/services/repo_sync_service.ts @@ -1,6 +1,7 @@ import { inject } from '@adonisjs/core' import logger from '@adonisjs/core/services/logger' import { IdentityService } from '#profile/services/identity_service' +import { ProfileService } from '#profile/services/profile_service' import { asAtprotoDid, DidResolverCommon, @@ -40,7 +41,10 @@ export interface BatchSyncResult { export class RepoSyncService { private didResolver = new DidResolverCommon() - constructor(private identityService: IdentityService) {} + constructor( + private identityService: IdentityService, + private profileService: ProfileService + ) {} async resolvePdsEndpoint(did: string): Promise { try { @@ -53,7 +57,7 @@ export class RepoSyncService { } } - private async syncKits(client: Client, repoDid: AtprotoDid, handle: string): Promise { + protected async syncKits(client: Client, repoDid: AtprotoDid): Promise { let count = 0 for await (const record of client.listAll(gunplaKit, { repo: repoDid })) { if (!record.valid) continue @@ -75,7 +79,6 @@ export class RepoSyncService { { cid: record.cid, did: repoDid, - handle, name: kit.name, grade: kit.grade ?? null, scale: kit.scale ?? null, @@ -93,11 +96,7 @@ export class RepoSyncService { return count } - private async syncBuildLogs( - client: Client, - repoDid: AtprotoDid, - handle: string - ): Promise { + protected async syncBuildLogs(client: Client, repoDid: AtprotoDid): Promise { let count = 0 for await (const record of client.listAll(gunplaBuildLog, { repo: repoDid })) { if (!record.valid) continue @@ -108,7 +107,6 @@ export class RepoSyncService { { cid: record.cid, did: repoDid, - handle, kitUri: log.kit, stage: log.stage ?? null, text: log.text ?? null, @@ -136,8 +134,10 @@ export class RepoSyncService { const client = new Client(pdsEndpoint) const repoDid = asAtprotoDid(did) - const kitsSynced = await this.syncKits(client, repoDid, handle) - const logsSynced = await this.syncBuildLogs(client, repoDid, handle) + const kitsSynced = await this.syncKits(client, repoDid) + const logsSynced = await this.syncBuildLogs(client, repoDid) + + await this.profileService.syncProfile(did, handle) return { did, @@ -156,20 +156,20 @@ export class RepoSyncService { for (const identifier of identifiers) { try { - const data = await this.syncUser(identifier) - totalKits += data.kitsSynced - totalLogs += data.logsSynced + const syncData = await this.syncUser(identifier) results.push({ identifier, success: true, - data, + data: syncData, }) - } catch (err) { + totalKits += syncData.kitsSynced + totalLogs += syncData.logsSynced + } catch (error) { hasFailures = true results.push({ identifier, success: false, - error: err instanceof Error ? err.message : String(err), + error: error instanceof Error ? error.message : 'Unknown error during sync', }) } } diff --git a/app/kits/transformers/build_log_transformer.ts b/app/kits/transformers/build_log_transformer.ts index bb71b17..dcc81b5 100644 --- a/app/kits/transformers/build_log_transformer.ts +++ b/app/kits/transformers/build_log_transformer.ts @@ -7,7 +7,6 @@ export default class BuildLogTransformer extends BaseTransformer { id: this.resource.id, uri: this.resource.uri, did: this.resource.did, - handle: this.resource.handle, kitUri: this.resource.kitUri, stage: this.resource.stage, text: this.resource.text, diff --git a/app/kits/transformers/kit_transformer.ts b/app/kits/transformers/kit_transformer.ts index 514f548..3c64d18 100644 --- a/app/kits/transformers/kit_transformer.ts +++ b/app/kits/transformers/kit_transformer.ts @@ -3,6 +3,7 @@ import type Kit from '#kits/models/kit' import { getBlobUrl } from '#kits/helpers/atproto' import { AtUri } from '@atproto/syntax' import BuildLogTransformer from '#kits/transformers/build_log_transformer' +import ProfileTransformer from '#profile/transformers/profile_transformer' export interface PhotoItem { cid: string @@ -32,12 +33,15 @@ export default class KitTransformer extends BaseTransformer { })) : [] + const handle = this.resource.profile?.handle ?? null + return { id: this.resource.id, uri: this.resource.uri, rkey, did, - handle: this.resource.handle, + handle, + author: ProfileTransformer.transform(this.whenLoaded(this.resource.profile)), name: this.resource.name, grade: this.resource.grade, scale: this.resource.scale, @@ -71,10 +75,13 @@ export default class KitTransformer extends BaseTransformer { })) : [] + const handle = this.resource.profile?.handle ?? null + return { id: this.resource.id, uri: this.resource.uri, - handle: this.resource.handle, + handle, + author: ProfileTransformer.transform(this.whenLoaded(this.resource.profile)), name: this.resource.name, manufacturer: this.resource.manufacturer, grade: this.resource.grade, diff --git a/app/profile/models/profile.ts b/app/profile/models/profile.ts new file mode 100644 index 0000000..3eb2edd --- /dev/null +++ b/app/profile/models/profile.ts @@ -0,0 +1,41 @@ +import { BaseModel, column, hasMany } from '@adonisjs/lucid/orm' +import type { HasMany } from '@adonisjs/lucid/types/relations' +import { DateTime } from 'luxon' +import Kit from '#kits/models/kit' +import BuildLog from '#kits/models/build_log' + +export default class Profile extends BaseModel { + static table = 'profiles' + static primaryKey = 'did' + static selfAssignPrimaryKey = true + + @column({ isPrimary: true }) + declare did: string + + @column() + declare handle: string + + @column() + declare displayName: string | null + + @column() + declare avatarUrl: string | null + + @column() + declare description: string | null + + @column.dateTime() + declare indexedAt: DateTime + + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null + + @hasMany(() => Kit, { foreignKey: 'did', localKey: 'did' }) + declare kits: HasMany + + @hasMany(() => BuildLog, { foreignKey: 'did', localKey: 'did' }) + declare buildLogs: HasMany +} diff --git a/app/profile/services/profile_service.ts b/app/profile/services/profile_service.ts index 28ae106..5ca0ff5 100644 --- a/app/profile/services/profile_service.ts +++ b/app/profile/services/profile_service.ts @@ -1,6 +1,8 @@ import { inject } from '@adonisjs/core' +import { DateTime } from 'luxon' import Kit from '#kits/models/kit' import BuildLog from '#kits/models/build_log' +import Profile from '#profile/models/profile' import KitTransformer from '#kits/transformers/kit_transformer' import BuildLogTransformer from '#kits/transformers/build_log_transformer' import { AchievementsService } from '#profile/services/achievements_service' @@ -35,33 +37,82 @@ export class ProfileService { } } - async getProfileData(handle: string) { - const identity = await this.identityService.resolve(handle) - if (!identity) { - return null + async syncProfile(did: string, knownHandle?: string): Promise { + const existing = await Profile.find(did) + const resolvedIdentity = await this.identityService.resolve(did) + const bskyProfile = await this.fetchBskyProfile(did) + + const handle = + knownHandle && !knownHandle.startsWith('did:') + ? knownHandle + : (resolvedIdentity?.handle ?? existing?.handle ?? did) + + const now = DateTime.now() + if (existing) { + existing.handle = handle + if (bskyProfile.displayName !== null) existing.displayName = bskyProfile.displayName + if (bskyProfile.avatarUrl !== null) existing.avatarUrl = bskyProfile.avatarUrl + if (bskyProfile.description !== null) existing.description = bskyProfile.description + existing.indexedAt = now + await existing.save() + return existing } - const [kits, buildLogs, bskyProfile] = await Promise.all([ + return Profile.create({ + did, + handle, + displayName: bskyProfile.displayName, + avatarUrl: bskyProfile.avatarUrl, + description: bskyProfile.description, + indexedAt: now, + }) + } + + async getOrCreate(did: string): Promise { + const existing = await Profile.find(did) + if (existing) return existing + return this.syncProfile(did) + } + + async getProfileData(handleOrDid: string) { + let profile = await Profile.query() + .where('handle', handleOrDid) + .orWhere('did', handleOrDid) + .first() + + if (!profile) { + const identity = await this.identityService.resolve(handleOrDid) + if (!identity) { + return null + } + profile = await Profile.find(identity.did) + if (!profile) { + profile = await this.syncProfile(identity.did, identity.handle) + } + } + + const [kits, buildLogs] = await Promise.all([ Kit.query() - .where((q) => q.where('handle', identity.handle).orWhere('did', identity.did)) + .where('did', profile.did) + .preload('profile') .orderBy('indexed_at', 'desc') .limit(100), BuildLog.query() - .where((q) => q.where('handle', identity.handle).orWhere('did', identity.did)) + .where('did', profile.did) + .preload('profile') .orderBy('indexed_at', 'desc') .limit(50), - this.fetchBskyProfile(identity.did), ]) const stats = this.achievementsService.computeStats(kits, buildLogs) const achievements = this.achievementsService.computeAchievements(kits, buildLogs) return { - handle: identity.handle, - did: identity.did, - avatarUrl: bskyProfile.avatarUrl, - description: bskyProfile.description, - displayName: bskyProfile.displayName, + handle: profile.handle, + did: profile.did, + avatarUrl: profile.avatarUrl, + description: profile.description, + displayName: profile.displayName, kits: KitTransformer.transform(kits), buildLogs: BuildLogTransformer.transform(buildLogs), stats, diff --git a/app/profile/transformers/profile_transformer.ts b/app/profile/transformers/profile_transformer.ts new file mode 100644 index 0000000..a52d92f --- /dev/null +++ b/app/profile/transformers/profile_transformer.ts @@ -0,0 +1,8 @@ +import { BaseTransformer } from '@adonisjs/core/transformers' +import type Profile from '#profile/models/profile' + +export default class ProfileTransformer extends BaseTransformer { + toObject() { + return this.pick(this.resource, ['did', 'handle', 'displayName', 'avatarUrl', 'description']) + } +} diff --git a/app/stats/services/stats_service.ts b/app/stats/services/stats_service.ts index c678313..41c429e 100644 --- a/app/stats/services/stats_service.ts +++ b/app/stats/services/stats_service.ts @@ -43,10 +43,11 @@ export default class StatsService { db .from('kits') - .select('handle') + .join('profiles', 'kits.did', 'profiles.did') + .select('profiles.handle as handle') .count('* as count') - .whereNotNull('handle') - .groupBy('handle') + .whereNotNull('profiles.handle') + .groupBy('profiles.handle') .orderBy('count', 'desc') .limit(5), diff --git a/database/factories/build_log_factory.ts b/database/factories/build_log_factory.ts index e6210e9..b69bc43 100644 --- a/database/factories/build_log_factory.ts +++ b/database/factories/build_log_factory.ts @@ -10,7 +10,6 @@ export const BuildLogFactory = Factory.define(BuildLog, ({ faker }) => { uri: `at://${did}/build.gunpla.buildLog/${rkey}`, cid: null, did, - handle: `${faker.internet.username().toLowerCase()}.bsky.social`, kitUri: `at://${did}/build.gunpla.kit/${kitRkey}`, stage: null, text: null, diff --git a/database/factories/kit_factory.ts b/database/factories/kit_factory.ts index 831bd89..673950c 100644 --- a/database/factories/kit_factory.ts +++ b/database/factories/kit_factory.ts @@ -9,7 +9,6 @@ export const KitFactory = Factory.define(Kit, ({ faker }) => { uri: `at://${did}/gunpla.kit/${rkey}`, cid: null, did, - handle: `${faker.internet.username().toLowerCase()}.bsky.social`, name: faker.commerce.productName(), grade: null, scale: null, diff --git a/database/factories/profile_factory.ts b/database/factories/profile_factory.ts new file mode 100644 index 0000000..8f53b17 --- /dev/null +++ b/database/factories/profile_factory.ts @@ -0,0 +1,16 @@ +import Factory from '@adonisjs/lucid/factories' +import { DateTime } from 'luxon' +import Profile from '#profile/models/profile' + +export const ProfileFactory = Factory.define(Profile, ({ faker }) => { + const did = `did:plc:${faker.string.alphanumeric(24)}` + const handle = `${faker.internet.username().toLowerCase()}.bsky.social` + return { + did, + handle, + displayName: faker.person.fullName(), + avatarUrl: faker.image.avatar(), + description: faker.person.bio(), + indexedAt: DateTime.now(), + } +}).build() diff --git a/database/migrations/1791100471941_create_kits_table.ts b/database/migrations/1791100471941_create_kits_table.ts index 577e70b..999e4e4 100644 --- a/database/migrations/1791100471941_create_kits_table.ts +++ b/database/migrations/1791100471941_create_kits_table.ts @@ -9,7 +9,6 @@ export default class extends BaseSchema { table.string('uri').notNullable().unique() table.string('cid').nullable() table.string('did').notNullable().index() - table.string('handle').nullable() table.string('name').notNullable() table.string('grade').nullable() table.string('scale').nullable() diff --git a/database/migrations/1791104183282_create_build_logs_table.ts b/database/migrations/1791104183282_create_build_logs_table.ts index aa421f3..a59a9fc 100644 --- a/database/migrations/1791104183282_create_build_logs_table.ts +++ b/database/migrations/1791104183282_create_build_logs_table.ts @@ -9,7 +9,6 @@ export default class extends BaseSchema { table.string('uri').notNullable().unique() table.string('cid').nullable() table.string('did').notNullable().index() - table.string('handle').nullable() table.string('kit_uri').notNullable().index() table.string('stage').nullable() table.text('text').nullable() diff --git a/database/migrations/1791354921383_create_profiles_table.ts b/database/migrations/1791354921383_create_profiles_table.ts new file mode 100644 index 0000000..94fb1a0 --- /dev/null +++ b/database/migrations/1791354921383_create_profiles_table.ts @@ -0,0 +1,22 @@ +import { BaseSchema } from '@adonisjs/lucid/schema' + +export default class extends BaseSchema { + protected tableName = 'profiles' + + async up() { + this.schema.createTable(this.tableName, (table) => { + table.string('did').primary().notNullable() + table.string('handle').notNullable().unique() + table.string('display_name').nullable() + table.string('avatar_url').nullable() + table.text('description').nullable() + table.timestamp('indexed_at').notNullable() + table.timestamp('created_at').notNullable() + table.timestamp('updated_at').nullable() + }) + } + + async down() { + this.schema.dropTable(this.tableName) + } +} diff --git a/database/schema.ts b/database/schema.ts index 6658426..7aa54e1 100644 --- a/database/schema.ts +++ b/database/schema.ts @@ -8,20 +8,7 @@ import { BaseModel, column } from '@adonisjs/lucid/orm' import { DateTime } from 'luxon' export class BuildLogSchema extends BaseModel { - static $columns = [ - 'cid', - 'createdAt', - 'did', - 'handle', - 'id', - 'indexedAt', - 'kitUri', - 'recordCreatedAt', - 'stage', - 'text', - 'updatedAt', - 'uri', - ] as const + static $columns = ['cid', 'createdAt', 'did', 'id', 'indexedAt', 'kitUri', 'recordCreatedAt', 'stage', 'text', 'updatedAt', 'uri'] as const $columns = BuildLogSchema.$columns @column() declare cid: string | null @@ -29,8 +16,6 @@ export class BuildLogSchema extends BaseModel { declare createdAt: DateTime | null @column() declare did: string - @column() - declare handle: string | null @column({ isPrimary: true }) declare id: number @column.dateTime() @@ -50,25 +35,7 @@ export class BuildLogSchema extends BaseModel { } export class KitSchema extends BaseModel { - static $columns = [ - 'acquiredAt', - 'cid', - 'createdAt', - 'did', - 'grade', - 'handle', - 'id', - 'indexedAt', - 'manufacturer', - 'name', - 'photoCid', - 'photos', - 'recordCreatedAt', - 'scale', - 'status', - 'updatedAt', - 'uri', - ] as const + static $columns = ['acquiredAt', 'cid', 'createdAt', 'did', 'grade', 'id', 'indexedAt', 'manufacturer', 'name', 'photoCid', 'photos', 'recordCreatedAt', 'scale', 'status', 'updatedAt', 'uri'] as const $columns = KitSchema.$columns @column.dateTime() declare acquiredAt: DateTime | null @@ -80,8 +47,6 @@ export class KitSchema extends BaseModel { declare did: string @column() declare grade: string | null - @column() - declare handle: string | null @column({ isPrimary: true }) declare id: number @column.dateTime() @@ -127,3 +92,24 @@ export class OauthStateSchema extends BaseModel { @column() declare value: string } + +export class ProfileSchema extends BaseModel { + static $columns = ['avatarUrl', 'createdAt', 'description', 'did', 'displayName', 'handle', 'indexedAt', 'updatedAt'] as const + $columns = ProfileSchema.$columns + @column() + declare avatarUrl: string | null + @column.dateTime({ autoCreate: true }) + declare createdAt: DateTime + @column() + declare description: string | null + @column({ isPrimary: true }) + declare did: string + @column() + declare displayName: string | null + @column() + declare handle: string + @column.dateTime() + declare indexedAt: DateTime + @column.dateTime({ autoCreate: true, autoUpdate: true }) + declare updatedAt: DateTime | null +} diff --git a/services/jetstream_service.ts b/services/jetstream_service.ts index 0e182ad..6aefdf6 100644 --- a/services/jetstream_service.ts +++ b/services/jetstream_service.ts @@ -4,6 +4,7 @@ import { inject } from '@adonisjs/core' import env from '#start/env' import Kit from '#kits/models/kit' import BuildLog from '#kits/models/build_log' +import Profile from '#profile/models/profile' import { DateTime } from 'luxon' import { AtUri } from '@atproto/syntax' import { lexParse, getBlobCidString, isBlobRef } from '@atproto/lex' @@ -48,7 +49,6 @@ export class JetstreamService { private pingInterval: NodeJS.Timeout | null = null private lastCursor: number | string | null = null private lastSavedCursorTime = 0 - private handleCache: Map = new Map() constructor(options: JetstreamServiceOptions = {}) { this.endpoint = @@ -85,16 +85,10 @@ export class JetstreamService { async resolveHandle(did: string): Promise { if (!did.startsWith('did:')) return null - if (this.handleCache.has(did)) return this.handleCache.get(did)! try { - const existing = await Kit.query() - .where('did', did) - .whereNotNull('handle') - .orderBy('indexed_at', 'desc') - .first() + const existing = await Profile.find(did) if (existing?.handle) { - this.handleCache.set(did, existing.handle) return existing.handle } } catch {} @@ -106,7 +100,10 @@ export class JetstreamService { if (res.ok) { const data = (await res.json()) as { handle?: string } if (data.handle) { - this.handleCache.set(did, data.handle) + await Profile.firstOrCreate( + { did }, + { did, handle: data.handle, indexedAt: DateTime.now() } + ) return data.handle } } @@ -119,7 +116,7 @@ export class JetstreamService { const aka = data.alsoKnownAs?.find((h) => h.startsWith('at://')) if (aka) { const handle = new AtUri(aka).hostname - this.handleCache.set(did, handle) + await Profile.firstOrCreate({ did }, { did, handle, indexedAt: DateTime.now() }) return handle } } @@ -148,17 +145,6 @@ export class JetstreamService { async processEvent(event: JetstreamEvent): Promise { this.saveCursor(event.time_us) - if (event.kind === 'identity') { - const { did, handle } = event.identity - if (handle) { - this.handleCache.set(did, handle) - await Kit.query().where('did', did).update({ handle }) - await BuildLog.query().where('did', did).update({ handle }) - await this.broadcast('kits', { type: 'identity', did, handle }) - } - return - } - if (event.kind !== 'commit') return const { collection, operation, rkey } = event.commit @@ -184,7 +170,7 @@ export class JetstreamService { ) return } - const handle = await this.resolveHandle(did) + await this.resolveHandle(did) const recordPhotos = (record as { photos?: unknown[] }).photos const photoCids: string[] = [] if (Array.isArray(recordPhotos)) { @@ -201,7 +187,6 @@ export class JetstreamService { { cid, did, - handle, name: record.name as string, grade: record.grade as string, scale: record.scale as string, @@ -234,13 +219,12 @@ export class JetstreamService { ) return } - const handle = await this.resolveHandle(did) + await this.resolveHandle(did) await BuildLog.updateOrCreate( { uri }, { cid, did, - handle, kitUri: record.kit as string, stage: record.stage as string, text: record.text as string | undefined, diff --git a/tests/functional/callback/handle.spec.ts b/tests/functional/callback/handle.spec.ts index b2fcced..e45153f 100644 --- a/tests/functional/callback/handle.spec.ts +++ b/tests/functional/callback/handle.spec.ts @@ -3,8 +3,10 @@ import testUtils from '@adonisjs/core/services/test_utils' import { KitFactory } from '#database/factories/kit_factory' import { AuthService } from '#auth/services/auth_service' import { RepoSyncService } from '#kits/services/repo_sync_service' +import { IdentityService } from '#profile/services/identity_service' import { FakeAuthService } from '#tests/helpers/fake_auth_service' import { FakeRepoSyncService } from '#tests/helpers/fake_repo_sync_service' +import { FakeIdentityService } from '#tests/helpers/fake_identity_service' test.group('CallbackController.handle', (group) => { group.each.setup(() => testUtils.db().truncate()) @@ -39,4 +41,39 @@ test.group('CallbackController.handle', (group) => { response.assertStatus(302) response.assertHeader('location', '/shelf') }) + + test('creates profile record with resolved handle, display name, and avatar on login callback', async ({ + client, + db, + swap, + }) => { + swap(AuthService, new FakeAuthService('did:plc:callbackuser')) + swap(RepoSyncService, new FakeRepoSyncService()) + swap(IdentityService, new FakeIdentityService({ defaultHandle: 'callback.bsky.social' })) + + const response = await client.visit('oauth.callback').redirects(0) + + response.assertStatus(302) + response.assertHeader('location', '/shelf') + await db.assertHas('profiles', { did: 'did:plc:callbackuser', handle: 'callback.bsky.social' }) + }) + + test('creates interim profile with did as handle when handle resolution is unreachable on new user login', async ({ + client, + db, + swap, + }) => { + swap(AuthService, new FakeAuthService('did:plc:unreachableuser')) + swap(RepoSyncService, new FakeRepoSyncService()) + swap(IdentityService, new FakeIdentityService({ defaultHandle: null })) + + const response = await client.visit('oauth.callback').redirects(0) + + response.assertStatus(302) + response.assertHeader('location', '/shelf') + await db.assertHas('profiles', { + did: 'did:plc:unreachableuser', + handle: 'did:plc:unreachableuser', + }) + }) }) diff --git a/tests/functional/explore/render.spec.ts b/tests/functional/explore/render.spec.ts index b58fb5c..c3fd9d8 100644 --- a/tests/functional/explore/render.spec.ts +++ b/tests/functional/explore/render.spec.ts @@ -2,6 +2,7 @@ import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' import { DateTime } from 'luxon' import { KitFactory } from '#database/factories/kit_factory' +import { ProfileFactory } from '#database/factories/profile_factory' import { BuildLogFactory } from '#database/factories/build_log_factory' test.group('ExploreController.render', (group) => { @@ -10,15 +11,21 @@ test.group('ExploreController.render', (group) => { test('explore renders completed kits with transformer-shaped fields and build logs', async ({ client, }) => { + await ProfileFactory.merge({ + did: 'did:plc:amuro', + handle: 'amuro.bsky.social', + }).create() + const kit = await KitFactory.merge({ + did: 'did:plc:amuro', status: 'done', name: 'HG Aerial', - handle: 'amuro.bsky.social', grade: 'HG', photoCid: 'bafyfakecid', }).create() await BuildLogFactory.merge({ + did: 'did:plc:amuro', kitUri: kit.uri, stage: 'Painting', text: 'Top coat done', @@ -44,6 +51,44 @@ test.group('ExploreController.render', (group) => { }) }) + test('explore renders completed kits with author attribution including avatar, display name, and handle', async ({ + client, + }) => { + await ProfileFactory.merge({ + did: 'did:plc:amuro', + handle: 'amuro.bsky.social', + displayName: 'Amuro Ray', + avatarUrl: 'https://cdn.bsky.app/avatar.jpg', + }).create() + + await KitFactory.merge({ + did: 'did:plc:amuro', + name: 'HG Aerial', + status: 'done', + photoCid: 'bafyfakecid', + }).create() + + const response = await client.visit('explore').withInertia() + + response.assertStatus(200) + response.assertInertiaComponent('explore/index') + response.assertInertiaPropsContains({ + kits: { + data: [ + { + name: 'HG Aerial', + handle: 'amuro.bsky.social', + author: { + handle: 'amuro.bsky.social', + displayName: 'Amuro Ray', + avatarUrl: 'https://cdn.bsky.app/avatar.jpg', + }, + }, + ], + }, + }) + }) + test('explore shows only completed kits, excluding non-completed', async ({ client, assert }) => { await KitFactory.merge({ status: 'done', name: 'HG Aerial' }).create() await KitFactory.merge({ status: 'in-progress', name: 'WIP Zaku' }).create() diff --git a/tests/functional/feed/render.spec.ts b/tests/functional/feed/render.spec.ts index c5bb2e5..d487053 100644 --- a/tests/functional/feed/render.spec.ts +++ b/tests/functional/feed/render.spec.ts @@ -1,14 +1,20 @@ import { test } from '@japa/runner' import testUtils from '@adonisjs/core/services/test_utils' import { KitFactory } from '#database/factories/kit_factory' +import { ProfileFactory } from '#database/factories/profile_factory' test.group('FeedController.render', (group) => { group.each.setup(() => testUtils.db().truncate()) test('home page renders kits list with name, handle, status', async ({ client }) => { + await ProfileFactory.merge({ + did: 'did:plc:amuro', + handle: 'amuro.bsky.social', + }).create() + await KitFactory.merge({ + did: 'did:plc:amuro', name: 'HG Aerial', - handle: 'amuro.bsky.social', status: 'done', photoCid: 'bafyfakecid', }).create() @@ -22,6 +28,42 @@ test.group('FeedController.render', (group) => { }) }) + test('home feed renders kit cards with author attribution including avatar, display name, and handle', async ({ + client, + }) => { + await ProfileFactory.merge({ + did: 'did:plc:amuro', + handle: 'amuro.bsky.social', + displayName: 'Amuro Ray', + avatarUrl: 'https://cdn.bsky.app/avatar.jpg', + }).create() + + await KitFactory.merge({ + did: 'did:plc:amuro', + name: 'HG Aerial', + status: 'done', + photoCid: 'bafyfakecid', + }).create() + + const response = await client.visit('home').withInertia() + + response.assertStatus(200) + response.assertInertiaComponent('home/index') + response.assertInertiaPropsContains({ + kits: [ + { + name: 'HG Aerial', + handle: 'amuro.bsky.social', + author: { + handle: 'amuro.bsky.social', + displayName: 'Amuro Ray', + avatarUrl: 'https://cdn.bsky.app/avatar.jpg', + }, + }, + ], + }) + }) + test('home page renders kits: [] when no kits exist', async ({ client }) => { const response = await client.visit('home').withInertia() diff --git a/tests/functional/kits.spec.ts b/tests/functional/kits.spec.ts index 8d89b73..ad059d7 100644 --- a/tests/functional/kits.spec.ts +++ b/tests/functional/kits.spec.ts @@ -35,6 +35,7 @@ test.group('Kits Management', (group) => { const response = await client .visit('kits.store') .withCsrfToken() + .header('referer', '/kits/new') .form({ name: 'HG Aerial', grade: 'HG', @@ -272,7 +273,7 @@ test.group('Kits Management', (group) => { await db.assertModelMissing(kit) }) - test('kit creation aborts and flashes error when handle cannot be resolved', async ({ + test('fails kit creation when handle resolution fails on first kit', async ({ client, db, swap, @@ -328,8 +329,11 @@ test.group('Kits Management', (group) => { await db.assertHas('kits', { name: 'HG Aerial', - handle: 'builder.bsky.social', did: 'did:plc:test', }) + await db.assertHas('profiles', { + did: 'did:plc:test', + handle: 'builder.bsky.social', + }) }) }) diff --git a/tests/functional/profile.spec.ts b/tests/functional/profile.spec.ts index 7902490..2dd495d 100644 --- a/tests/functional/profile.spec.ts +++ b/tests/functional/profile.spec.ts @@ -4,6 +4,7 @@ import Kit from '#kits/models/kit' import BuildLog from '#kits/models/build_log' import { DateTime } from 'luxon' import { IdentityService } from '#profile/services/identity_service' +import { ProfileFactory } from '#database/factories/profile_factory' class FakeIdentityService extends IdentityService { override async resolve(identifier: string) { @@ -66,7 +67,6 @@ test.group('Profile', (group) => { await Kit.create({ uri: `at://${did}/build.gunpla.kit/kit1`, did, - handle, name: 'HG Aerial', status: 'done', photoCid: 'bafyfakecid', @@ -76,7 +76,6 @@ test.group('Profile', (group) => { await BuildLog.create({ uri: `at://${did}/build.gunpla.buildLog/log1`, did, - handle, kitUri: `at://${did}/build.gunpla.kit/kit1`, stage: 'Painting', recordCreatedAt: DateTime.fromISO('2024-03-15'), @@ -102,13 +101,13 @@ test.group('Profile', (group) => { swap(IdentityService, new FakeIdentityService()) const handle = 'amuro.bsky.social' + const did = 'did:plc:amurobskysocial' const grades = ['HG', 'RG', 'MG', 'PG', 'SD', 'EG'] for (const [index, grade] of grades.entries()) { await Kit.create({ - uri: `at://did:plc:amuro/build.gunpla.kit/${index}`, - did: 'did:plc:amuro', - handle, + uri: `at://${did}/build.gunpla.kit/${index}`, + did, name: `Kit ${grade}`, grade, scale: '1/144', @@ -118,7 +117,7 @@ test.group('Profile', (group) => { } await db.assertCount('kits', 6) - await db.assertHas('kits', { handle, grade: 'PG' }) + await db.assertHas('kits', { did, grade: 'PG' }) const response = await client.visit('profile', { handle }).withInertia() @@ -142,4 +141,30 @@ test.group('Profile', (group) => { assert.isTrue(gauntlet?.unlocked) assert.equal(gauntlet?.progress?.current, 6) }) + + test('renders profile page with persisted display name, avatar, and bio from profiles record', async ({ + client, + swap, + }) => { + await ProfileFactory.merge({ + did: 'did:plc:amurobskysocial', + handle: 'amuro.bsky.social', + displayName: 'Amuro Ray', + avatarUrl: 'https://cdn.bsky.app/avatar.jpg', + description: 'Gundam Pilot', + }).create() + + swap(IdentityService, new FakeIdentityService()) + + const response = await client.visit('profile', { handle: 'amuro.bsky.social' }).withInertia() + + response.assertStatus(200) + response.assertInertiaComponent('profile') + response.assertInertiaPropsContains({ + handle: 'amuro.bsky.social', + displayName: 'Amuro Ray', + avatarUrl: 'https://cdn.bsky.app/avatar.jpg', + description: 'Gundam Pilot', + }) + }) }) diff --git a/tests/helpers/fake_auth_middleware_with_user.ts b/tests/helpers/fake_auth_middleware_with_user.ts index 0d1f60c..2aad5bf 100644 --- a/tests/helpers/fake_auth_middleware_with_user.ts +++ b/tests/helpers/fake_auth_middleware_with_user.ts @@ -12,10 +12,7 @@ export default class FakeAuthMiddlewareWithUser extends AuthMiddleware { const fakeUser = Object.create(AtprotoUser.prototype) as AtprotoUser Object.defineProperty(fakeUser, 'did', { get: () => this.fakeDid }) - // ctx.auth.use('web').user = fakeUser - // ctx.containerResolver.bindValue(AtprotoUser, fakeUser) - - ;(ctx.auth.use('web') as any).user = fakeUser + ctx.auth.use('web').user = fakeUser ctx.containerResolver.bindValue(AtprotoUser, fakeUser) return next() diff --git a/tests/helpers/fake_kits_service.ts b/tests/helpers/fake_kits_service.ts index af23a37..88d8eaf 100644 --- a/tests/helpers/fake_kits_service.ts +++ b/tests/helpers/fake_kits_service.ts @@ -2,9 +2,10 @@ 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 Profile from '#profile/models/profile' +import CannotResolveHandleException from '#kits/exceptions/cannot_resolve_handle_exception' import { asAtUriString } from '@atproto/syntax' import { l } from '@atproto/lex' import { DateTime } from 'luxon' @@ -45,6 +46,11 @@ export class FakeKitsService extends KitsService { } const handle = identity.handle + await Profile.firstOrCreate( + { did: 'did:plc:test' }, + { did: 'did:plc:test', handle, indexedAt: DateTime.now() } + ) + const photoCids: string[] = [] if (data.photo) photoCids.push('bafyfakephotocid') if (Array.isArray(data.photos)) { @@ -55,17 +61,16 @@ export class FakeKitsService extends KitsService { 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', @@ -73,27 +78,6 @@ export class FakeKitsService extends KitsService { } 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: { @@ -108,6 +92,6 @@ export class FakeKitsService extends KitsService { } override async delete(rkey: string) { - await Kit.query().where('uri', 'like', `%/${rkey}`).delete() + await Kit.query().where('uri', `at://did:plc:test/build.gunpla.kit/${rkey}`).delete() } } diff --git a/tests/helpers/fake_profile_service.ts b/tests/helpers/fake_profile_service.ts new file mode 100644 index 0000000..0105380 --- /dev/null +++ b/tests/helpers/fake_profile_service.ts @@ -0,0 +1,25 @@ +import { ProfileService } from '#profile/services/profile_service' +import { AchievementsService } from '#profile/services/achievements_service' +import { FakeIdentityService } from '#tests/helpers/fake_identity_service' +import type Profile from '#profile/models/profile' + +export interface FakeProfileOptions { + onSyncProfile?: (did: string, handle?: string) => Promise | Profile | null +} + +export class FakeProfileService extends ProfileService { + public syncedProfiles: Array<{ did: string; handle?: string }> = [] + + constructor(private options: FakeProfileOptions = {}) { + super(new AchievementsService(), new FakeIdentityService()) + } + + override async syncProfile(did: string, handle?: string): Promise { + this.syncedProfiles.push({ did, handle }) + if (this.options.onSyncProfile) { + const result = await this.options.onSyncProfile(did, handle) + return result! + } + return undefined as unknown as Profile + } +} diff --git a/tests/helpers/fake_repo_sync_service.ts b/tests/helpers/fake_repo_sync_service.ts index 9bc2df5..2a907b7 100644 --- a/tests/helpers/fake_repo_sync_service.ts +++ b/tests/helpers/fake_repo_sync_service.ts @@ -11,7 +11,7 @@ export interface FakeRepoSyncOptions { export class FakeRepoSyncService extends RepoSyncService { constructor(private options: FakeRepoSyncOptions = {}) { - super(null as any) + super(null as any, null as any) } async syncUser(identifier: string): Promise { diff --git a/tests/helpers/testable_repo_sync_service.ts b/tests/helpers/testable_repo_sync_service.ts new file mode 100644 index 0000000..a63feda --- /dev/null +++ b/tests/helpers/testable_repo_sync_service.ts @@ -0,0 +1,33 @@ +import { RepoSyncService } from '#kits/services/repo_sync_service' +import type { IdentityService } from '#profile/services/identity_service' +import type { ProfileService } from '#profile/services/profile_service' +import type { Client } from '@atproto/lex' +import type { AtprotoDid } from '@atproto/oauth-client-node' + +export interface TestableRepoSyncOptions { + pdsEndpoint?: string | null + kitsSynced?: number + logsSynced?: number +} + +export class TestableRepoSyncService extends RepoSyncService { + constructor( + identityService: IdentityService, + profileService: ProfileService, + private options: TestableRepoSyncOptions = {} + ) { + super(identityService, profileService) + } + + public override async resolvePdsEndpoint(): Promise { + return this.options.pdsEndpoint ?? 'https://mock.pds' + } + + protected override async syncKits(_client: Client, _repoDid: AtprotoDid): Promise { + return this.options.kitsSynced ?? 2 + } + + protected override async syncBuildLogs(_client: Client, _repoDid: AtprotoDid): Promise { + return this.options.logsSynced ?? 1 + } +} diff --git a/tests/unit/kits_service.spec.ts b/tests/unit/kits_service.spec.ts index a290d00..4199e8f 100644 --- a/tests/unit/kits_service.spec.ts +++ b/tests/unit/kits_service.spec.ts @@ -10,14 +10,11 @@ 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, - }) => { + test('resolves user handle on first kit creation and links to profile', async ({ db }) => { const did = 'did:plc:builder123' await KitFactory.merge({ did, - handle: 'old-handle.bsky.social', }).create() const fakeIdentityService = new FakeIdentityService({ @@ -46,17 +43,13 @@ test.group('KitsService', (group) => { await db.assertHas('kits', { name: 'RG Zeta', - handle: 'new-handle.bsky.social', + did, }) - await db.assertHas( - 'kits', - { - did, - handle: 'new-handle.bsky.social', - }, - 2 - ) + await db.assertHas('profiles', { + did, + handle: 'new-handle.bsky.social', + }) }) test('throws CannotResolveHandleException without calling remote PDS when handle resolution fails', async ({ @@ -98,6 +91,7 @@ test.group('KitsService', (group) => { assert.isFalse(clientCreateCalled) assert.isFalse(clientUploadBlobCalled) + await db.assertEmpty('kits') }) }) diff --git a/tests/unit/profile_service.spec.ts b/tests/unit/profile_service.spec.ts new file mode 100644 index 0000000..d889c7b --- /dev/null +++ b/tests/unit/profile_service.spec.ts @@ -0,0 +1,59 @@ +import { test } from '@japa/runner' +import testUtils from '@adonisjs/core/services/test_utils' +import { ProfileService } from '#profile/services/profile_service' +import { AchievementsService } from '#profile/services/achievements_service' +import { FakeIdentityService } from '../helpers/fake_identity_service.js' +import { ProfileFactory } from '#database/factories/profile_factory' + +test.group('ProfileService', (group) => { + group.each.setup(() => testUtils.db().truncate()) + + test('syncProfile upserts profile with resolved handle, display name, and avatar', async ({ + db, + assert, + }) => { + const fakeIdentityService = new FakeIdentityService({ defaultHandle: 'amuro.bsky.social' }) + const service = new ProfileService(new AchievementsService(), fakeIdentityService) + + const profile = await service.syncProfile('did:plc:amuro123') + + await db.assertHas('profiles', { did: 'did:plc:amuro123', handle: 'amuro.bsky.social' }) + assert.equal(profile.did, 'did:plc:amuro123') + assert.equal(profile.handle, 'amuro.bsky.social') + }) + + test('syncProfile preserves existing handle when ATProto resolution fails', async ({ db }) => { + await ProfileFactory.merge({ + did: 'did:plc:existing123', + handle: 'existing.bsky.social', + displayName: 'Existing Builder', + }).create() + + const fakeIdentityService = new FakeIdentityService({ defaultHandle: null }) + const service = new ProfileService(new AchievementsService(), fakeIdentityService) + + await service.syncProfile('did:plc:existing123') + + await db.assertHas('profiles', { + did: 'did:plc:existing123', + handle: 'existing.bsky.social', + display_name: 'Existing Builder', + }) + }) + + test('syncProfile creates interim profile with did as fallback handle when initial resolution fails', async ({ + db, + assert, + }) => { + const fakeIdentityService = new FakeIdentityService({ defaultHandle: null }) + const service = new ProfileService(new AchievementsService(), fakeIdentityService) + + const profile = await service.syncProfile('did:plc:greenfield123') + + await db.assertHas('profiles', { + did: 'did:plc:greenfield123', + handle: 'did:plc:greenfield123', + }) + assert.equal(profile.handle, 'did:plc:greenfield123') + }) +}) diff --git a/tests/unit/repo_sync_service.spec.ts b/tests/unit/repo_sync_service.spec.ts index 56cd1da..6132e31 100644 --- a/tests/unit/repo_sync_service.spec.ts +++ b/tests/unit/repo_sync_service.spec.ts @@ -1,15 +1,19 @@ import { test } from '@japa/runner' import { IdentityService } from '#profile/services/identity_service' import { RepoSyncService, type SyncResult } from '#kits/services/repo_sync_service' +import { FakeProfileService } from '#tests/helpers/fake_profile_service' +import { FakeIdentityService } from '#tests/helpers/fake_identity_service' +import { TestableRepoSyncService } from '#tests/helpers/testable_repo_sync_service' test.group('RepoSyncService', () => { const identityService = new IdentityService() - const syncService = new RepoSyncService(identityService) + const fakeProfileService = new FakeProfileService() + const syncService = new RepoSyncService(identityService, fakeProfileService) test('resolves PDS endpoint for a known DID', async ({ assert }) => { const pds = await syncService.resolvePdsEndpoint('did:plc:abiaoe3hpp2cqiuptrss75fp') assert.isNotNull(pds) - assert.match(pds!, /^https?:\/\//) + assert.isTrue(pds!.startsWith('https://') || pds!.startsWith('http://')) }) test('returns null for an unresolvable DID', async ({ assert }) => { @@ -25,7 +29,7 @@ test.group('RepoSyncService', () => { }) test('syncUsers aggregates results for multiple identifiers', async ({ assert }) => { - const customService = new RepoSyncService(identityService) + const customService = new RepoSyncService(identityService, fakeProfileService) customService.syncUser = async (id: string): Promise => { if (id === 'user1.bsky.social') { return { @@ -55,7 +59,7 @@ test.group('RepoSyncService', () => { }) test('syncUsers isolates individual errors and marks hasFailures true', async ({ assert }) => { - const customService = new RepoSyncService(identityService) + const customService = new RepoSyncService(identityService, fakeProfileService) customService.syncUser = async (id: string): Promise => { if (id === 'invalid.bsky.social') { throw new Error('Connection refused to PDS') @@ -78,4 +82,24 @@ test.group('RepoSyncService', () => { assert.equal(result.totalKits, 4) assert.equal(result.totalLogs, 2) }) + + test('syncUser invokes ProfileService.syncProfile when profileService is injected', async ({ + assert, + }) => { + const fakeIdentity = new FakeIdentityService({ + resolver: () => ({ did: 'did:plc:abiaoe3hpp2cqiuptrss75fp', handle: 'mockuser.bsky.social' }), + }) + const fakeProfile = new FakeProfileService() + const service = new TestableRepoSyncService(fakeIdentity, fakeProfile, { + kitsSynced: 2, + logsSynced: 1, + }) + + const result = await service.syncUser('mockuser.bsky.social') + assert.equal(result.did, 'did:plc:abiaoe3hpp2cqiuptrss75fp') + assert.equal(result.kitsSynced, 2) + assert.lengthOf(fakeProfile.syncedProfiles, 1) + assert.equal(fakeProfile.syncedProfiles[0].did, 'did:plc:abiaoe3hpp2cqiuptrss75fp') + assert.equal(fakeProfile.syncedProfiles[0].handle, 'mockuser.bsky.social') + }) }) -- 2.54.0