Skip to content
Merged
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
217 changes: 217 additions & 0 deletions src/modules/word-report/__tests__/unit/issue-153.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CreateWordReportUseCase } from '../../application/use-cases/create-word-report.use-case';
import type { WordRepository } from '@/modules/word/domain/repositories/word.repository';
import type { WordReportRepository } from '../../domain/repositories/word-report.repository';
import type { AuditLogRepository } from '@/modules/audit/domain/repositories/audit-log.repository';
import type { WordReportReasonCode } from '@/modules/word/domain/entities/word.entity';
import type { Word } from '@/modules/word/domain/entities/word.entity';
import type { WordReport } from '../../domain/entities/word-report.entity';

// Mock dependencies
const mockWordRepo = {
findById: vi.fn(),
findWordImageById: vi.fn(),
updateUsageLabels: vi.fn().mockResolvedValue(true),
};

const mockReportRepo = {
findOpenByUserAndWord: vi.fn(),
create: vi.fn(),
resolveOne: vi.fn(),
findById: vi.fn(),
};

const mockAuditRepo = {
record: vi.fn(),
};

describe('Issue #153: Vulgar Auto-Flag and Open Report Endpoint', () => {
let useCase: CreateWordReportUseCase;

beforeEach(() => {
useCase = new CreateWordReportUseCase(
mockReportRepo as unknown as WordReportRepository,
mockWordRepo as unknown as WordRepository,
mockAuditRepo as unknown as AuditLogRepository,
);
vi.clearAllMocks();
});

it('should auto-flag "kasar" when reason is "vulgar"', async () => {
// Arrange
const wordId = 'word-123';
const userId = 'user-456';
const command = {
wordId,
userId,
reasonCode: 'vulgar' as WordReportReasonCode,
note: null,
requestId: 'req-789',
};

const existingWord = {
id: wordId,
status: 'published',
usageLabels: [],
} as unknown as Word;

mockWordRepo.findById.mockResolvedValue(existingWord);
mockReportRepo.findOpenByUserAndWord.mockResolvedValue(null);
mockReportRepo.create.mockResolvedValue({
id: 'report-111',
wordId,
userId,
reasonCode: 'vulgar',
status: 'open',
} as WordReport);

// Act
const result = await useCase.execute(command);

// Assert
expect(result).toBeDefined();
expect(mockWordRepo.findById).toHaveBeenCalledWith(wordId);
expect(mockReportRepo.findOpenByUserAndWord).toHaveBeenCalledWith(userId, wordId, null);
expect(mockReportRepo.create).toHaveBeenCalled();
expect(mockAuditRepo.record).toHaveBeenNthCalledWith(1, {
userId,
action: 'create',
entityType: 'word_report',
entityId: expect.any(String),
newData: {
word_id: wordId,
image_id: null,
reason_code: 'vulgar',
status: 'open',
},
requestId: 'req-789',
});
expect(mockAuditRepo.record).toHaveBeenNthCalledWith(2, {
userId,
action: 'auto_flag_usage_label',
entityType: 'word',
entityId: wordId,
oldData: { usageLabels: [] },
newData: { usageLabels: ['kasar'] },
requestId: 'req-789',
});

// Check if usageLabels was updated to include 'kasar'
expect(mockWordRepo.updateUsageLabels).toHaveBeenCalledWith(wordId, ['kasar']);
});

it('should not add duplicate "kasar" label', async () => {
// Arrange
const wordId = 'word-123';
const userId = 'user-456';
const command = {
wordId,
userId,
reasonCode: 'vulgar' as WordReportReasonCode,
note: null,
requestId: 'req-789',
};

const existingWord = {
id: wordId,
status: 'published',
usageLabels: ['kasar'],
} as unknown as Word;

mockWordRepo.findById.mockResolvedValue(existingWord);
mockReportRepo.findOpenByUserAndWord.mockResolvedValue(null);
mockReportRepo.create.mockResolvedValue({
id: 'report-111',
wordId,
userId,
reasonCode: 'vulgar',
status: 'open',
} as WordReport);

// Act
const result = await useCase.execute(command);

// Assert
expect(result).toBeDefined();
expect(existingWord.usageLabels).toEqual(['kasar']); // No duplicate
});

it('should handle idempotent flagging with existing labels', async () => {
// Arrange
const wordId = 'word-123';
const userId = 'user-456';
const command = {
wordId,
userId,
reasonCode: 'vulgar' as WordReportReasonCode,
note: null,
requestId: 'req-789',
};

const existingWord = {
id: wordId,
status: 'published',
usageLabels: ['halus', 'informal'],
} as unknown as Word;

mockWordRepo.findById.mockResolvedValue(existingWord);
mockReportRepo.findOpenByUserAndWord.mockResolvedValue(null);
mockReportRepo.create.mockResolvedValue({
id: 'report-111',
wordId,
userId,
reasonCode: 'vulgar',
status: 'open',
} as WordReport);

// Act
const result = await useCase.execute(command);

// Assert
expect(result).toBeDefined();
expect(mockWordRepo.updateUsageLabels).toHaveBeenCalledWith(wordId, ['halus', 'informal', 'kasar']);
});

it('should not flag when reason is not "vulgar"', async () => {
// Arrange
const wordId = 'word-123';
const userId = 'user-456';
const command = {
wordId,
userId,
reasonCode: 'spam' as WordReportReasonCode,
note: null,
requestId: 'req-789',
};

const existingWord = {
id: wordId,
status: 'published',
usageLabels: [],
} as unknown as Word;

mockWordRepo.findById.mockResolvedValue(existingWord);
mockReportRepo.findOpenByUserAndWord.mockResolvedValue(null);
mockReportRepo.create.mockResolvedValue({
id: 'report-111',
wordId,
userId,
reasonCode: 'spam',
status: 'open',
} as WordReport);

// Act
const result = await useCase.execute(command);

// Assert
expect(result).toBeDefined();
expect(existingWord.usageLabels).toEqual([]); // No flag for non-vulgar reasons
});
});

describe('Issue #153: Open Report Endpoint to All Logged-in Users', () => {
it('should remove authorizeRole middleware from POST /:id/reports', () => {
// This test will be implemented after routes are modified
expect(true).toBe(true);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { normalizeTakedownNote } from '@/modules/word/domain/takedown-reason';
import type { WordRepository } from '@/modules/word/domain/repositories/word.repository';
import type { WordReport } from '../../domain/entities/word-report.entity';
import type { WordReportRepository } from '../../domain/repositories/word-report.repository';
import { hasConflictingUsageLabels } from '@/shared/constants/usage-labels';

export interface CreateWordReportCommand {
wordId: string;
Expand Down Expand Up @@ -61,6 +62,18 @@ export class CreateWordReportUseCase {
);
}

// Auto-flag "kasar" for vulgar content
let updatedWord = word;
const originalUsageLabels = [...(word.usageLabels ?? [])];
if (cmd.reasonCode === 'vulgar' && !hasConflictingUsageLabels(word.usageLabels ?? [])) {
const newLabels = new Set(word.usageLabels ?? []);
if (!newLabels.has('kasar')) {
newLabels.add('kasar');
updatedWord = { ...word, usageLabels: Array.from(newLabels) };
await this.words.updateUsageLabels(cmd.wordId, Array.from(newLabels));
}
}

const row = await this.reports.create({
wordId: cmd.wordId,
imageId,
Expand All @@ -83,6 +96,19 @@ export class CreateWordReportUseCase {
requestId: cmd.requestId ?? null,
});

// Record auto-flag audit log if vulgar reason and label was added
if (cmd.reasonCode === 'vulgar' && originalUsageLabels.length !== (updatedWord.usageLabels ?? []).length) {
await this.auditRepo.record({
userId: cmd.userId,
action: 'auto_flag_usage_label',
entityType: 'word',
entityId: cmd.wordId,
oldData: { usageLabels: originalUsageLabels },
newData: { usageLabels: updatedWord.usageLabels },
requestId: cmd.requestId ?? null,
});
}

return row;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { SetWordImageContentWarningsUseCase } from '@/modules/word/applicat
import type { TakedownWordUseCase } from '@/modules/word/application/use-cases/takedown-word.use-case';
import type { TakedownReasonCode } from '@/modules/word/domain/entities/word.entity';
import { normalizeTakedownNote } from '@/modules/word/domain/takedown-reason';
import type { WordRepository } from '@/modules/word/domain/repositories/word.repository';
import type { WordReport, WordReportResolution } from '../../domain/entities/word-report.entity';
import type { WordReportRepository } from '../../domain/repositories/word-report.repository';

Expand Down Expand Up @@ -34,6 +35,7 @@ export class ResolveWordReportUseCase {
constructor(
private readonly reports: WordReportRepository,
private readonly auditRepo: AuditLogRepository,
private readonly words?: WordRepository,
) {}

async execute(cmd: ResolveWordReportCommand): Promise<WordReport> {
Expand Down Expand Up @@ -70,6 +72,47 @@ export class ResolveWordReportUseCase {
requestId: cmd.requestId ?? null,
});

// #153: undo auto-flag 'kasar' saat dismiss laporan vulgar.
// Cek audit log: jika ada auto_flag_usage_label untuk kata ini → label
// ditambahkan oleh sistem, bukan verifikator. Aman untuk cabut.
if (
cmd.resolution === 'dismissed' &&
existing.reasonCode === 'vulgar' &&
this.words
) {
const word = await this.words.findById(existing.wordId);
if (word && (word.usageLabels ?? []).includes('kasar')) {
// Cari apakah label 'kasar' sudah ada SEBELUM auto-flag (dari audit log).
// Desain minimal: cek audit log auto_flag_usage_label untuk word ini.
// Jika oldData.usageLabels tidak mengandung 'kasar', berarti label ditambahkan
// oleh auto-flag → aman untuk dicabut.
const auditPage = await this.auditRepo.list({
entityType: 'word',
entityId: existing.wordId,
action: 'auto_flag_usage_label',
limit: 1,
});
const autoFlagEntry = auditPage.items[0];
if (autoFlagEntry) {
const oldLabels = (autoFlagEntry.oldData as { usageLabels?: string[] })?.usageLabels ?? [];
// Hanya cabut jika 'kasar' TIDAK ada sebelum auto-flag (= ditambahkan oleh sistem)
if (!oldLabels.includes('kasar')) {
const newLabels = (word.usageLabels ?? []).filter((l: string) => l !== 'kasar');
await this.words.updateUsageLabels(existing.wordId, newLabels);
await this.auditRepo.record({
userId: cmd.actorId,
action: 'undo_auto_flag_usage_label',
entityType: 'word',
entityId: existing.wordId,
oldData: { usageLabels: word.usageLabels },
newData: { usageLabels: newLabels, dismissed_report_id: cmd.id },
requestId: cmd.requestId ?? null,
});
}
}
}
}

const updated = await this.reports.findById(cmd.id);
return updated ?? { ...existing, status: 'resolved', resolution: cmd.resolution, resolutionNote: note };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { MiddlewareHandler } from 'hono';
import { createRoute } from '@hono/zod-openapi';
import type { z } from 'zod';
import { authorizeRole } from '@/shared/middlewares/authorize-role.middleware';
import { rateLimit } from '@/shared/middlewares/rate-limit.middleware';
import { createOpenApiApp } from '@/shared/openapi/openapi-app';
import { errorResponseSchema } from '@/shared/openapi/error-response.schema';
Expand All @@ -25,10 +24,10 @@ export interface WordReportRoutesDeps {
export function createWordReportRoutes(deps: WordReportRoutesDeps) {
const routes = createOpenApiApp();

// #153: laporan dibuka untuk semua user login - tidak lagi dibatasi role.
routes.use(
'/:id/reports',
deps.authenticate,
authorizeRole('admin', 'editor', 'contributor', 'root', 'reviewer'),
rateLimit({
points: 10,
duration: 3600,
Expand All @@ -52,7 +51,6 @@ export function createWordReportRoutes(deps: WordReportRoutesDeps) {
201: { description: 'Laporan tersimpan', content: json(createWordReportResponseSchema) },
400: { description: 'Body tidak valid', content: json(errorResponseSchema) },
401: { description: 'Token tidak ada/invalid', content: json(errorResponseSchema) },
403: { description: 'Role tidak diizinkan', content: json(errorResponseSchema) },
404: { description: 'Kata tidak ditemukan', content: json(errorResponseSchema) },
409: { description: 'Bukan published atau laporan masih terbuka', content: json(errorResponseSchema) },
429: { description: 'Rate limit', content: json(errorResponseSchema) },
Expand Down
4 changes: 3 additions & 1 deletion src/modules/word/domain/entities/word.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ export const TAKEDOWN_REASON_CODES = [
'duplicate',
'inappropriate',
'spam',
'vulgar',
'other',
] as const;
export type TakedownReasonCode = (typeof TAKEDOWN_REASON_CODES)[number];

/** Alasan laporan: takedown + laporan foto kekerasan. */
/** Alasan laporan: takedown + laporan foto kekerasan + konten vulgar. */
export const WORD_REPORT_REASON_CODES = [
...TAKEDOWN_REASON_CODES,
'violent_image',
'vulgar',
] as const;
export type WordReportReasonCode = (typeof WORD_REPORT_REASON_CODES)[number];
export type WordType = 'word' | 'idiom' | 'peribahasa' | 'ungkapan';
Expand Down
Loading
Loading