From 1240f91d6096bb7d820d33e2e4cbd7d21d3a46a1 Mon Sep 17 00:00:00 2001 From: m1sk9 Date: Sun, 21 Jul 2024 17:25:10 +0900 Subject: [PATCH 1/4] feat: Add `InMemoryNoteRepository` --- pkg/notes/adaptor/repository/dummy.ts | 72 +++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/pkg/notes/adaptor/repository/dummy.ts b/pkg/notes/adaptor/repository/dummy.ts index edf9cc58..7ca6730b 100644 --- a/pkg/notes/adaptor/repository/dummy.ts +++ b/pkg/notes/adaptor/repository/dummy.ts @@ -4,10 +4,12 @@ import type { AccountID } from '../../../accounts/model/account.js'; import type { Medium, MediumID } from '../../../drive/model/medium.js'; import { Bookmark } from '../../model/bookmark.js'; import type { Note, NoteID } from '../../model/note.js'; +import { Reaction } from '../../model/reaction.js'; import type { BookmarkRepository, NoteAttachmentRepository, NoteRepository, + ReactionRepository, } from '../../model/repository.js'; export class InMemoryNoteRepository implements NoteRepository { @@ -127,6 +129,76 @@ export class InMemoryBookmarkRepository implements BookmarkRepository { } } +export class InMemoryReactionRepository implements ReactionRepository { + private readonly reactions: Map<[NoteID, AccountID], Reaction>; + + private equalID(a: [NoteID, AccountID], b: [NoteID, AccountID]): boolean { + return a[0] === b[0] && a[1] === b[1]; + } + + constructor(reactions: Reaction[] = []) { + this.reactions = new Map( + reactions.map((reaction) => [ + [reaction.getNoteID(), reaction.getAccountID()], + reaction, + ]), + ); + } + + async create( + id: { + noteID: NoteID; + accountID: AccountID; + }, + emoji: string, + ): Promise> { + const reaction = Reaction.new({ ...id, emoji }); + this.reactions.set([id.noteID, id.accountID], reaction); + return Result.ok(undefined); + } + + async deleteByID(id: { + noteID: NoteID; + accountID: AccountID; + }): Promise> { + const key = Array.from(this.reactions.keys()).find((k) => + this.equalID(k, [id.noteID, id.accountID]), + ); + + if (!key) { + return Result.err(new Error('reaction not found')); + } + + this.reactions.delete(key); + return Result.ok(undefined); + } + + async findByID(id: { + noteID: NoteID; + accountID: AccountID; + }): Promise> { + const reaction = Array.from(this.reactions.entries()).find((v) => + this.equalID(v[0], [id.noteID, id.accountID]), + ); + if (!reaction) { + return Promise.resolve(Option.none()); + } + return Promise.resolve(Option.some(reaction[1])); + } + + async findByAccountID(id: AccountID): Promise> { + const reactions = Array.from(this.reactions.entries()) + .filter((v) => v[0][1] === id) + .map((v) => v[1]); + + if (reactions.length === 0) { + return Promise.resolve(Option.none()); + } + + return Promise.resolve(Option.some(reactions)); + } +} + export class InMemoryNoteAttachmentRepository implements NoteAttachmentRepository { From 969e8e21c2c9a0c6a96c7be546165ffe3b971a19 Mon Sep 17 00:00:00 2001 From: m1sk9 Date: Sun, 21 Jul 2024 17:25:33 +0900 Subject: [PATCH 2/4] feat: Add `Reaction` model --- pkg/notes/model/reaction.test.ts | 21 +++++++++++++++++++ pkg/notes/model/reaction.ts | 35 ++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 pkg/notes/model/reaction.test.ts create mode 100644 pkg/notes/model/reaction.ts diff --git a/pkg/notes/model/reaction.test.ts b/pkg/notes/model/reaction.test.ts new file mode 100644 index 00000000..297c6fca --- /dev/null +++ b/pkg/notes/model/reaction.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; + +import type { AccountID } from '../../accounts/model/account.js'; +import type { NoteID } from './note.js'; +import { type CreateReactionArgs, Reaction } from './reaction.js'; + +const exampleInput: CreateReactionArgs = { + noteID: '1' as NoteID, + accountID: '2' as AccountID, + emoji: '👍', +}; + +describe('Reaction', () => { + it('add reaction to note', () => { + const reaction = Reaction.new(exampleInput); + + expect(reaction.getNoteID()).toBe(exampleInput.noteID); + expect(reaction.getAccountID()).toBe(exampleInput.accountID); + expect(reaction.getEmoji()).toBe(exampleInput.emoji); + }); +}); diff --git a/pkg/notes/model/reaction.ts b/pkg/notes/model/reaction.ts new file mode 100644 index 00000000..79fc070a --- /dev/null +++ b/pkg/notes/model/reaction.ts @@ -0,0 +1,35 @@ +import type { AccountID } from '../../accounts/model/account.js'; +import type { NoteID } from './note.js'; + +export interface CreateReactionArgs { + noteID: NoteID; + accountID: AccountID; + emoji: string; +} + +export class Reaction { + private constructor(arg: CreateReactionArgs) { + this.noteID = arg.noteID; + this.accountID = arg.accountID; + this.emoji = arg.emoji; + } + + static new(arg: CreateReactionArgs) { + return new Reaction(arg); + } + + private readonly noteID: NoteID; + getNoteID(): NoteID { + return this.noteID; + } + + private readonly accountID: AccountID; + getAccountID(): AccountID { + return this.accountID; + } + + private readonly emoji: string; + getEmoji(): string { + return this.emoji; + } +} From 0976ff15e4ba0b48fc2d912780185f10342fe3a5 Mon Sep 17 00:00:00 2001 From: m1sk9 Date: Sun, 21 Jul 2024 17:25:55 +0900 Subject: [PATCH 3/4] feat: Add `ReactionRepository` model --- pkg/notes/model/repository.ts | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/pkg/notes/model/repository.ts b/pkg/notes/model/repository.ts index 17f6db93..25ad839d 100644 --- a/pkg/notes/model/repository.ts +++ b/pkg/notes/model/repository.ts @@ -4,6 +4,7 @@ import type { AccountID } from '../../accounts/model/account.js'; import type { Medium, MediumID } from '../../drive/model/medium.js'; import type { Bookmark } from './bookmark.js'; import type { Note, NoteID } from './note.js'; +import type { Reaction } from './reaction.js'; export interface NoteRepository { create(note: Note): Promise>; @@ -31,6 +32,25 @@ export interface BookmarkRepository { }): Promise>; } +export interface ReactionRepository { + create( + id: { + noteID: NoteID; + accountID: AccountID; + }, + emoji: string, + ): Promise>; + findByID(id: { + noteID: NoteID; + accountID: AccountID; + }): Promise>; + findByAccountID(id: AccountID): Promise>; + deleteByID(id: { + noteID: NoteID; + accountID: AccountID; + }): Promise>; +} + export interface NoteAttachmentRepository { create( noteID: NoteID, From 7b38ac9d544961139db40f202504da1a233ec630 Mon Sep 17 00:00:00 2001 From: m1sk9 Date: Sun, 21 Jul 2024 17:26:31 +0900 Subject: [PATCH 4/4] feat: Add `CreateReactionService` --- pkg/notes/service/createReaction.test.ts | 124 +++++++++++++++++++++++ pkg/notes/service/createReaction.ts | 45 ++++++++ 2 files changed, 169 insertions(+) create mode 100644 pkg/notes/service/createReaction.test.ts create mode 100644 pkg/notes/service/createReaction.ts diff --git a/pkg/notes/service/createReaction.test.ts b/pkg/notes/service/createReaction.test.ts new file mode 100644 index 00000000..8e21dc21 --- /dev/null +++ b/pkg/notes/service/createReaction.test.ts @@ -0,0 +1,124 @@ +import { Option, Result } from '@mikuroxina/mini-fn'; +import { describe, expect, it } from 'vitest'; +import type { AccountID } from '../../accounts/model/account.js'; +import { + InMemoryNoteRepository, + InMemoryReactionRepository, +} from '../adaptor/repository/dummy.js'; +import { Note, type NoteID } from '../model/note.js'; +import { CreateReactionService } from './createReaction.js'; + +const noteID = 'noteID_1' as NoteID; +const anotherNoteID = 'noteID_2' as NoteID; +const accountID = 'accountID_1' as AccountID; +const anotherAccountID = 'accountID_2' as AccountID; + +const reactionRepository = new InMemoryReactionRepository(); +const noteRepository = new InMemoryNoteRepository([ + Note.new({ + id: 'noteID_1' as NoteID, + authorID: '3' as AccountID, + content: 'Hello world', + contentsWarningComment: '', + createdAt: new Date('2023-09-10T00:00:00Z'), + sendTo: Option.none(), + originalNoteID: Option.none(), + attachmentFileID: [], + visibility: 'PUBLIC', + }), + Note.new({ + id: 'noteID_2' as NoteID, + authorID: '3' as AccountID, + content: 'Another note', + contentsWarningComment: '', + createdAt: new Date('2023-09-10T00:00:00Z'), + sendTo: Option.none(), + originalNoteID: Option.none(), + attachmentFileID: [], + visibility: 'PUBLIC', + }), +]); +const createReactionService = new CreateReactionService( + reactionRepository, + noteRepository, +); + +describe('CreateReactionService', () => { + it('success to create reaction', async () => { + const res = await createReactionService.handle(noteID, accountID, '👍'); + + expect(Result.isOk(res)).toBe(true); + expect( + Option.isSome(await reactionRepository.findByID({ noteID, accountID })), + ).toBe(true); + }); + + it('fail to re-create reaction from same account', async () => { + const res = await createReactionService.handle(noteID, accountID, '👍'); + + expect(Result.isErr(res)).toBe(true); + }); + + it('fail when note not found', async () => { + const res = await createReactionService.handle( + 'note_notexist' as NoteID, + accountID, + '👍', + ); + expect(Result.isErr(res)).toBe(true); + }); + + it('success to create bookmark for author note', async () => { + const res = await createReactionService.handle( + noteID, + '3' as AccountID, + '👍', + ); + + expect(Result.isOk(res)).toBe(true); + expect( + Option.isSome( + await reactionRepository.findByID({ + noteID, + accountID: '3' as AccountID, + }), + ), + ).toBe(true); + }); + + it('success to create reaction for another note', async () => { + const res = await createReactionService.handle( + anotherNoteID, + accountID, + '👍', + ); + + expect(Result.isOk(res)).toBe(true); + expect( + Option.isSome( + await reactionRepository.findByID({ + noteID: anotherNoteID, + accountID, + }), + ), + ).toBe(true); + }); + + it('success to create reaction from another account', async () => { + const res = await createReactionService.handle( + noteID, + anotherAccountID, + '👍', + ); + + expect(Result.isOk(res)).toBe(true); + expect( + Option.isSome( + await reactionRepository.findByID({ + noteID, + accountID: anotherAccountID, + }), + ), + ).toBe(true); + }); +}); diff --git a/pkg/notes/service/createReaction.ts b/pkg/notes/service/createReaction.ts new file mode 100644 index 00000000..38540e13 --- /dev/null +++ b/pkg/notes/service/createReaction.ts @@ -0,0 +1,45 @@ +import { Option, Result } from '@mikuroxina/mini-fn'; + +import type { AccountID } from '../../accounts/model/account.js'; +import type { Note, NoteID } from '../model/note.js'; +import type { + NoteRepository, + ReactionRepository, +} from '../model/repository.js'; + +export class CreateReactionService { + constructor( + private readonly reactionRepository: ReactionRepository, + private readonly noteRepository: NoteRepository, + ) {} + + async handle( + noteID: NoteID, + accountID: AccountID, + emoji: string, + ): Promise> { + const note = await this.noteRepository.findByID(noteID); + if (Option.isNone(note)) { + return Result.err(new Error('Note not found')); + } + + const existReaction = await this.reactionRepository.findByID({ + noteID, + accountID, + }); + + if (Option.isSome(existReaction)) { + return Result.err(new Error('reaction has already created')); + } + + const creation = await this.reactionRepository.create( + { + noteID, + accountID, + }, + emoji, + ); + + return Result.map(() => Option.unwrap(note))(creation); + } +}