Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Add CreateReactionService #578

Closed
wants to merge 4 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions pkg/notes/adaptor/repository/dummy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,12 @@
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 {
Expand Down Expand Up @@ -127,6 +129,76 @@
}
}

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,

Check warning on line 143 in pkg/notes/adaptor/repository/dummy.ts

View check run for this annotation

Codecov / codecov/patch

pkg/notes/adaptor/repository/dummy.ts#L142-L143

Added lines #L142 - L143 were not covered by tests
]),
);
}

async create(
id: {
noteID: NoteID;
accountID: AccountID;
},
emoji: string,
): Promise<Result.Result<Error, void>> {
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<Result.Result<Error, void>> {
const key = Array.from(this.reactions.keys()).find((k) =>
this.equalID(k, [id.noteID, id.accountID]),
);

Check warning on line 166 in pkg/notes/adaptor/repository/dummy.ts

View check run for this annotation

Codecov / codecov/patch

pkg/notes/adaptor/repository/dummy.ts#L163-L166

Added lines #L163 - L166 were not covered by tests

if (!key) {
return Result.err(new Error('reaction not found'));
}

Check warning on line 170 in pkg/notes/adaptor/repository/dummy.ts

View check run for this annotation

Codecov / codecov/patch

pkg/notes/adaptor/repository/dummy.ts#L168-L170

Added lines #L168 - L170 were not covered by tests

this.reactions.delete(key);
return Result.ok(undefined);
}

Check warning on line 174 in pkg/notes/adaptor/repository/dummy.ts

View check run for this annotation

Codecov / codecov/patch

pkg/notes/adaptor/repository/dummy.ts#L172-L174

Added lines #L172 - L174 were not covered by tests

async findByID(id: {
noteID: NoteID;
accountID: AccountID;
}): Promise<Option.Option<Reaction>> {
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<Option.Option<Reaction[]>> {
const reactions = Array.from(this.reactions.entries())
.filter((v) => v[0][1] === id)
.map((v) => v[1]);

Check warning on line 192 in pkg/notes/adaptor/repository/dummy.ts

View check run for this annotation

Codecov / codecov/patch

pkg/notes/adaptor/repository/dummy.ts#L190-L192

Added lines #L190 - L192 were not covered by tests

if (reactions.length === 0) {
return Promise.resolve(Option.none());
}

Check warning on line 196 in pkg/notes/adaptor/repository/dummy.ts

View check run for this annotation

Codecov / codecov/patch

pkg/notes/adaptor/repository/dummy.ts#L194-L196

Added lines #L194 - L196 were not covered by tests

return Promise.resolve(Option.some(reactions));
}

Check warning on line 199 in pkg/notes/adaptor/repository/dummy.ts

View check run for this annotation

Codecov / codecov/patch

pkg/notes/adaptor/repository/dummy.ts#L198-L199

Added lines #L198 - L199 were not covered by tests
}

export class InMemoryNoteAttachmentRepository
implements NoteAttachmentRepository
{
Expand Down
21 changes: 21 additions & 0 deletions pkg/notes/model/reaction.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
35 changes: 35 additions & 0 deletions pkg/notes/model/reaction.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}
20 changes: 20 additions & 0 deletions pkg/notes/model/repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Result.Result<Error, void>>;
Expand Down Expand Up @@ -31,6 +32,25 @@ export interface BookmarkRepository {
}): Promise<Result.Result<Error, void>>;
}

export interface ReactionRepository {
create(
id: {
noteID: NoteID;
accountID: AccountID;
},
emoji: string,
): Promise<Result.Result<Error, void>>;
findByID(id: {
noteID: NoteID;
accountID: AccountID;
}): Promise<Option.Option<Reaction>>;
findByAccountID(id: AccountID): Promise<Option.Option<Reaction[]>>;
deleteByID(id: {
noteID: NoteID;
accountID: AccountID;
}): Promise<Result.Result<Error, void>>;
}

export interface NoteAttachmentRepository {
create(
noteID: NoteID,
Expand Down
124 changes: 124 additions & 0 deletions pkg/notes/service/createReaction.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
45 changes: 45 additions & 0 deletions pkg/notes/service/createReaction.ts
Original file line number Diff line number Diff line change
@@ -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<Result.Result<Error, Note>> {
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);
}
}