feat: multiple improvements to library functionality (#50)
Node.js CI / CI (push) Successful in 1m18s
Security Scan and Upload / Security & DefectDojo Upload (push) Successful in 1m17s

## Summary

This PR implements several improvements to the library application:

- Added start and finish date tracking for media items
- Added "Retired" category for abandoned media
- Implemented avatar-based user menu with dropdown navigation
- Added automatic background token refresh to prevent session expiry
- Created centralised logging system with frontend-to-API log forwarding
- Added toast notifications for error handling

## Changes

### Media Tracking (#41)
- Added `dateStarted` and `dateFinished` fields to Books, Games, Manga, Music, and Shows
- Updated TypeScript types, Prisma schema, and API services
- Added manual date input fields to frontend forms
- Properly converts HTML date strings to Date objects before API submission

### Retired Category (#43)
- Added `RETIRED` status to all media type enums
- Updated Prisma schema, frontend dropdowns, and filter buttons
- Added status label handling for retired items

### User Menu (#46)
- Replaced username text with avatar image in header
- Created dropdown menu with navigation items (Users, Audit, Suggestions)
- Added logout button to menu
- Implemented keyboard accessibility (tabindex, role, keyup handlers)

### Token Refresh (#44)
- Implemented automatic token refresh every 13 minutes in background
- Added proactive refresh to prevent token expiry during form filling
- Prevents users from losing form data due to expired sessions

### Centralised Logging (#1)
- Created `/log` endpoint on API to receive frontend logs
- Replaced API console.log calls with @nhcarrigan/logger
- Created ConsoleLoggerService to intercept all console methods on frontend
- Added global error handlers (window.error, unhandledrejection) on frontend
- Added process error handlers (uncaughtException, unhandledRejection, SIGTERM, SIGINT) on API
- All frontend console activity now forwarded to centralised logging

### Error Handling
- Created ToastService and ToastComponent for displaying errors
- Integrated with GlobalErrorHandler and HTTP interceptor
- Added accessibility features (keyboard navigation, ARIA attributes)
- Set toast opacity to 40% for optimal readability

### Testing & Build
- Fixed pre-existing test failure for GET / route (now returns version info)
- Added ESM module mocking (jsdom, marked, dompurify, @nhcarrigan/logger)
- Configured Jest with isolatedModules to handle TypeScript errors
- Excluded test-setup.ts from production build
- All tests passing (123 total)
- Build passing with no errors

## Test Plan

- [x] All tests pass (123 tests)
- [x] Build passes without errors
- [x] Lint passes (only pre-existing warnings)
- [x] Date fields work correctly on all media types
- [x] Retired status displays and filters properly
- [x] Avatar menu opens/closes correctly with keyboard and mouse
- [x] Token refresh prevents session expiry
- [x] Toast notifications appear for errors
- [x] Frontend logs forward to API successfully
- [x] Root route returns version information

Closes #41
Closes #43
Closes #44
Closes #46
Closes #1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Hikari <hikari@nhcarrigan.com>
Reviewed-on: #50
Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com>
Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
This commit was merged in pull request #50.
This commit is contained in:
2026-02-19 16:52:43 -08:00
committed by Naomi Carrigan
parent 9caf74945a
commit 7579f1ec97
93 changed files with 4297 additions and 645 deletions
+102 -8
View File
@@ -3,19 +3,113 @@ import { fileURLToPath } from 'url';
import { dirname } from 'path';
const __dirname = dirname(fileURLToPath(import.meta.url));
const nhcarriganArray = Array.isArray(nhcarrigan) ? nhcarrigan : [nhcarrigan];
// Jest globals that should be available in test files
const jestGlobals = {
afterAll: 'readonly',
afterEach: 'readonly',
beforeAll: 'readonly',
beforeEach: 'readonly',
describe: 'readonly',
expect: 'readonly',
it: 'readonly',
jest: 'readonly',
test: 'readonly',
};
// Map the nhcarrigan configs to handle shared-types directory structure
const mappedConfigs = nhcarriganArray.flatMap(config => {
if (!config.files) {
return [config];
}
const newFiles = config.files
.map(pattern => {
if (pattern.startsWith('src/')) {
return pattern.replace('src/', 'shared-types/src/');
} else if (pattern.startsWith('test/')) {
return pattern.replace('test/', 'shared-types/test/');
}
return pattern;
});
// Determine if this is a test file config
const isTestFile = newFiles[0]?.includes('test/');
// Update configs to handle shared-types directory structure
let updatedConfig = { ...config, files: newFiles };
if (config.languageOptions) {
const updatedLanguageOptions = { ...config.languageOptions };
// Add Jest globals for test files
if (isTestFile) {
updatedLanguageOptions.globals = { ...updatedLanguageOptions.globals, ...jestGlobals };
}
// Update parserOptions to use our tsconfig with proper tsconfigRootDir
if (config.languageOptions.parserOptions) {
updatedLanguageOptions.parserOptions = {
...config.languageOptions.parserOptions,
tsconfigRootDir: __dirname,
};
}
updatedConfig = { ...updatedConfig, languageOptions: updatedLanguageOptions };
}
return [updatedConfig];
});
export default [
...nhcarrigan,
{
ignores: ['**/dist', '**/out-tsc', 'node_modules'],
ignores: ['dist', 'out-tsc', 'node_modules'],
},
...mappedConfigs,
// Disable vitest rules for this Jest project
{
files: ['**/*.ts'],
languageOptions: {
parserOptions: {
project: './tsconfig.lib.json',
tsconfigRootDir: __dirname,
},
files: ['shared-types/test/**/*.spec.ts'],
rules: {
'vitest/consistent-test-filename': 'off',
'vitest/consistent-test-it': 'off',
'vitest/expect-expect': 'off',
'vitest/no-alias-methods': 'off',
'vitest/no-commented-out-tests': 'off',
'vitest/no-conditional-expect': 'off',
'vitest/no-conditional-in-test': 'off',
'vitest/no-conditional-tests': 'off',
'vitest/no-disabled-tests': 'off',
'vitest/no-duplicate-hooks': 'off',
'vitest/no-focused-tests': 'off',
'vitest/no-identical-title': 'off',
'vitest/no-standalone-expect': 'off',
'vitest/no-test-prefixes': 'off',
'vitest/no-test-return-statement': 'off',
'vitest/prefer-comparison-matcher': 'off',
'vitest/prefer-each': 'off',
'vitest/prefer-equality-matcher': 'off',
'vitest/prefer-expect-assertions': 'off',
'vitest/prefer-expect-resolves': 'off',
'vitest/prefer-hooks-in-order': 'off',
'vitest/prefer-hooks-on-top': 'off',
'vitest/prefer-lowercase-title': 'off',
'vitest/prefer-mock-promise-shorthand': 'off',
'vitest/prefer-spy-on': 'off',
'vitest/prefer-strict-equal': 'off',
'vitest/prefer-to-be': 'off',
'vitest/prefer-to-be-falsy': 'off',
'vitest/prefer-to-be-object': 'off',
'vitest/prefer-to-be-truthy': 'off',
'vitest/prefer-to-contain': 'off',
'vitest/prefer-to-have-length': 'off',
'vitest/prefer-todo': 'off',
'vitest/require-hook': 'off',
'vitest/require-to-throw-message': 'off',
'vitest/require-top-level-describe': 'off',
'vitest/valid-describe-callback': 'off',
'vitest/valid-expect': 'off',
'vitest/valid-title': 'off',
},
},
];
+22
View File
@@ -0,0 +1,22 @@
export default {
displayName: "shared-types",
preset: "../jest.preset.js",
testEnvironment: "node",
transform: {
"^.+\\.[tj]s$": ["ts-jest", { tsconfig: "<rootDir>/tsconfig.spec.json" }],
},
moduleFileExtensions: ["ts", "js", "html"],
coverageDirectory: "../coverage/shared-types",
coverageThreshold: {
global: {
branches: 100,
functions: 100,
lines: 100,
statements: 100,
},
},
collectCoverageFrom: [
"src/**/*.ts",
"!src/index.ts", // Just re-exports
],
};
+16 -1
View File
@@ -5,5 +5,20 @@
"projectType": "library",
"tags": [],
"// targets": "to see all targets run: nx show project shared-types --web",
"targets": {}
"targets": {
"lint": {
"executor": "@nx/eslint:lint",
"options": {
"lintFilePatterns": ["shared-types/**/*.ts"]
}
},
"test": {
"executor": "@nx/jest:jest",
"options": {
"jestConfig": "shared-types/jest.config.ts",
"passWithNoTests": true
},
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"]
}
}
}
+11 -11
View File
@@ -1,17 +1,17 @@
/**
* @copyright 2026 NHCarrigan
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export * from "./lib/game.types";
export * from "./lib/book.types";
export * from "./lib/music.types";
export * from "./lib/art.types";
export * from "./lib/show.types";
export * from "./lib/manga.types";
export type * from "./lib/auth.types";
export * from "./lib/comment.types";
export type * from "./lib/art.types";
export * from "./lib/audit.types";
export type * from "./lib/auth.types";
export * from "./lib/book.types";
export type * from "./lib/comment.types";
export type * from "./lib/common.types";
export * from "./lib/game.types";
export type * from "./lib/like.types";
export * from "./lib/manga.types";
export * from "./lib/music.types";
export * from "./lib/show.types";
export * from "./lib/suggestion.types";
export * from "./lib/common.types";
export * from "./lib/like.types";
+22 -19
View File
@@ -1,30 +1,33 @@
/**
* @copyright 2026 NHCarrigan
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { Link } from "./common.types";
import type { Link } from "./common.types";
export interface Art {
id: string;
title: string;
artist: string;
interface Art {
id: string;
title: string;
artist: string;
description?: string;
imageUrl: string;
tags: string[];
links: Link[];
dateAdded: Date;
createdAt: Date;
updatedAt: Date;
imageUrl: string;
tags: Array<string>;
links: Array<Link>;
dateAdded: Date;
createdAt: Date;
updatedAt: Date;
}
export interface CreateArtDto {
title: string;
artist: string;
interface CreateArtDto {
title: string;
artist: string;
description?: string;
imageUrl: string;
tags?: string[];
links?: Link[];
imageUrl: string;
tags?: Array<string>;
links?: Array<Link>;
}
export interface UpdateArtDto extends Partial<CreateArtDto> {}
type UpdateArtDto = Partial<CreateArtDto>;
export type { Art, CreateArtDto, UpdateArtDto };
+59 -45
View File
@@ -1,58 +1,72 @@
export enum AuditAction {
LOGIN = "LOGIN",
LOGOUT = "LOGOUT",
LOGIN_FAILED = "LOGIN_FAILED",
COMMENT_CREATE = "COMMENT_CREATE",
COMMENT_UPDATE = "COMMENT_UPDATE",
COMMENT_DELETE = "COMMENT_DELETE",
ENTRY_CREATE = "ENTRY_CREATE",
ENTRY_UPDATE = "ENTRY_UPDATE",
ENTRY_DELETE = "ENTRY_DELETE",
LIKE = "LIKE",
UNLIKE = "UNLIKE",
USER_BAN = "USER_BAN",
USER_UNBAN = "USER_UNBAN",
RATE_LIMIT_EXCEEDED = "RATE_LIMIT_EXCEEDED",
CSRF_VALIDATION_FAILED = "CSRF_VALIDATION_FAILED",
UNAUTHORIZED_ACCESS = "UNAUTHORIZED_ACCESS",
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
enum AuditAction {
login = "LOGIN",
logout = "LOGOUT",
loginFailed = "LOGIN_FAILED",
commentCreate = "COMMENT_CREATE",
commentUpdate = "COMMENT_UPDATE",
commentDelete = "COMMENT_DELETE",
entryCreate = "ENTRY_CREATE",
entryUpdate = "ENTRY_UPDATE",
entryDelete = "ENTRY_DELETE",
like = "LIKE",
unlike = "UNLIKE",
userBan = "USER_BAN",
userUnban = "USER_UNBAN",
rateLimitExceeded = "RATE_LIMIT_EXCEEDED",
csrfValidationFailed = "CSRF_VALIDATION_FAILED",
unauthorizedAccess = "UNAUTHORIZED_ACCESS",
}
export enum AuditCategory {
AUTH = "AUTH",
CONTENT = "CONTENT",
ADMIN = "ADMIN",
SECURITY = "SECURITY",
enum AuditCategory {
auth = "AUTH",
content = "CONTENT",
admin = "ADMIN",
security = "SECURITY",
}
export interface AuditLogUser {
id: string;
interface AuditLogUser {
id: string;
username: string;
avatar?: string;
avatar?: string;
}
export interface AuditLog {
id: string;
action: AuditAction;
category: AuditCategory;
userId?: string;
user?: AuditLogUser;
interface AuditLog {
id: string;
action: AuditAction;
category: AuditCategory;
userId?: string;
user?: AuditLogUser;
targetUserId?: string;
targetUser?: AuditLogUser;
targetUser?: AuditLogUser;
resourceType?: string;
resourceId?: string;
details?: string;
userAgent?: string;
success: boolean;
createdAt: Date;
resourceId?: string;
details?: string;
userAgent?: string;
success: boolean;
createdAt: Date;
}
export interface AuditLogFilters {
action?: AuditAction;
category?: AuditCategory;
userId?: string;
success?: boolean;
interface AuditLogFilters {
action?: AuditAction;
category?: AuditCategory;
userId?: string;
success?: boolean;
startDate?: Date;
endDate?: Date;
page?: number;
limit?: number;
endDate?: Date;
page?: number;
limit?: number;
}
export {
AuditAction,
AuditCategory,
type AuditLog,
type AuditLogFilters,
type AuditLogUser,
};
+22 -19
View File
@@ -4,33 +4,36 @@
* @author Naomi Carrigan
*/
export interface User {
id: string;
email: string;
username: string;
avatar?: string;
interface User {
id: string;
email: string;
username: string;
avatar?: string;
discordId: string;
isAdmin: boolean;
isBanned: boolean;
isAdmin: boolean;
isBanned: boolean;
inDiscord: boolean;
isVip: boolean;
isMod: boolean;
isStaff: boolean;
isVip: boolean;
isMod: boolean;
isStaff: boolean;
}
export interface JwtPayload {
interface JwtPayload {
/**
* User id.
*/
sub: string;
email: string;
sub: string;
email: string;
username: string;
isAdmin: boolean;
iat?: number;
exp?: number;
isAdmin: boolean;
iat?: number;
exp?: number;
}
export interface AuthResponse {
interface AuthResponse {
accessToken: string;
user: User;
}
user: User;
}
export type { AuthResponse, JwtPayload, User };
+26 -19
View File
@@ -1,46 +1,53 @@
/**
* @copyright 2026 NHCarrigan
* @copyright 2026 NHCarrigan
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export enum BookStatus {
import type { Link } from "./common.types";
enum BookStatus {
reading = "READING",
finished = "FINISHED",
toRead = "TO_READ",
retired = "RETIRED",
}
import { Link } from "./common.types";
export interface Book {
interface Book {
id: string;
title: string;
author: string;
isbn?: string;
status: BookStatus;
dateAdded: Date;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags: string[];
links: Link[];
tags: Array<string>;
links: Array<Link>;
createdAt: Date;
updatedAt: Date;
}
export interface CreateBookDto {
title: string;
author: string;
isbn?: string;
status: BookStatus;
rating?: number;
notes?: string;
coverImage?: string;
tags?: string[];
links?: Link[];
interface CreateBookDto {
title: string;
author: string;
isbn?: string;
status: BookStatus;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags?: Array<string>;
links?: Array<Link>;
}
export interface UpdateBookDto extends Partial<CreateBookDto> {
interface UpdateBookDto extends Partial<CreateBookDto> {
dateStarted?: Date;
dateFinished?: Date;
}
export { type Book, BookStatus, type CreateBookDto, type UpdateBookDto };
+23 -21
View File
@@ -4,32 +4,34 @@
* @author Naomi Carrigan
*/
export interface CommentUser {
id: string;
username: string;
avatar?: string;
interface CommentUser {
id: string;
username: string;
avatar?: string;
inDiscord?: boolean;
isVip?: boolean;
isMod?: boolean;
isStaff?: boolean;
isVip?: boolean;
isMod?: boolean;
isStaff?: boolean;
}
export interface Comment {
id: string;
content: string;
interface Comment {
id: string;
content: string;
rawContent?: string;
userId: string;
user: CommentUser;
gameId?: string;
bookId?: string;
musicId?: string;
artId?: string;
showId?: string;
mangaId?: string;
createdAt: Date;
updatedAt: Date;
userId: string;
user: CommentUser;
gameId?: string;
bookId?: string;
musicId?: string;
artId?: string;
showId?: string;
mangaId?: string;
createdAt: Date;
updatedAt: Date;
}
export interface CreateCommentDto {
interface CreateCommentDto {
content: string;
}
export type { Comment, CommentUser, CreateCommentDto };
+7 -1
View File
@@ -1,4 +1,10 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export interface Link {
title: string;
url: string;
url: string;
}
+27 -18
View File
@@ -1,44 +1,53 @@
/**
* @copyright 2026 NHCarrigan
* @copyright 2026 NHCarrigan
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export enum GameStatus {
import type { Link } from "./common.types";
enum GameStatus {
playing = "PLAYING",
completed = "COMPLETED",
backlog = "BACKLOG",
retired = "RETIRED",
}
import { Link } from "./common.types";
export interface Game {
interface Game {
id: string;
title: string;
platform?: string;
status: GameStatus;
dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags: string[];
links: Link[];
tags: Array<string>;
links: Array<Link>;
createdAt: Date;
updatedAt: Date;
}
export interface CreateGameDto {
title: string;
platform?: string;
status: GameStatus;
rating?: number;
notes?: string;
coverImage?: string;
tags?: string[];
links?: Link[];
interface CreateGameDto {
title: string;
platform?: string;
status: GameStatus;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags?: Array<string>;
links?: Array<Link>;
}
export interface UpdateGameDto extends Partial<CreateGameDto> {
interface UpdateGameDto extends Partial<CreateGameDto> {
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
}
export { type CreateGameDto, type Game, GameStatus, type UpdateGameDto };
+21 -15
View File
@@ -1,32 +1,38 @@
/**
* @copyright 2026 NHCarrigan
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export interface Like {
id: string;
userId: string;
entityType: 'book' | 'game' | 'show' | 'manga' | 'music' | 'art';
entityId: string;
createdAt: Date;
interface Like {
id: string;
userId: string;
entityType: "book" | "game" | "show" | "manga" | "music" | "art";
entityId: string;
createdAt: Date;
}
export type CreateLikeDto = Pick<Like, 'entityType' | 'entityId'>;
type CreateLikeDto = Pick<Like, "entityType" | "entityId">;
export interface LikeCountDto {
entityId: string;
interface LikeCountDto {
entityId: string;
entityType: string;
count: number;
count: number;
}
export interface LikedItemDto {
interface LikedItemDto {
like: Like;
item: any; // This will be the actual entity (Book, Game, etc.)
/**
* This will be the actual entity (Book, Game, etc.).
*/
item: unknown;
}
// Response types
export interface LikeResponse {
interface LikeResponse {
liked: boolean;
count: number;
}
}
export type { CreateLikeDto, Like, LikeCountDto, LikedItemDto, LikeResponse };
+27 -17
View File
@@ -1,44 +1,54 @@
/**
* @copyright 2026 NHCarrigan
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export enum MangaStatus {
import type { Link } from "./common.types";
enum MangaStatus {
reading = "READING",
completed = "COMPLETED",
wantToRead = "WANT_TO_READ",
retired = "RETIRED",
}
import { Link } from "./common.types";
export interface Manga {
interface Manga {
id: string;
title: string;
author: string;
status: MangaStatus;
dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags: string[];
links: Link[];
tags: Array<string>;
links: Array<Link>;
createdAt: Date;
updatedAt: Date;
}
export interface CreateMangaDto {
title: string;
author: string;
status: MangaStatus;
rating?: number;
notes?: string;
coverImage?: string;
tags?: string[];
links?: Link[];
interface CreateMangaDto {
title: string;
author: string;
status: MangaStatus;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags?: Array<string>;
links?: Array<Link>;
}
export interface UpdateMangaDto extends Partial<CreateMangaDto> {
interface UpdateMangaDto extends Partial<CreateMangaDto> {
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
}
export { MangaStatus };
export type { CreateMangaDto, Manga, UpdateMangaDto };
+30 -20
View File
@@ -1,52 +1,62 @@
/**
* @copyright 2026 NHCarrigan
* @copyright 2026 NHCarrigan
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export enum MusicType {
import type { Link } from "./common.types";
enum MusicType {
album = "ALBUM",
single = "SINGLE",
ep = "EP",
}
export enum MusicStatus {
enum MusicStatus {
listening = "LISTENING",
completed = "COMPLETED",
wantToListen = "WANT_TO_LISTEN",
retired = "RETIRED",
}
import { Link } from "./common.types";
export interface Music {
interface Music {
id: string;
title: string;
artist: string;
type: MusicType;
status: MusicStatus;
dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverArt?: string;
tags: string[];
links: Link[];
tags: Array<string>;
links: Array<Link>;
createdAt: Date;
updatedAt: Date;
}
export interface CreateMusicDto {
title: string;
artist: string;
type: MusicType;
status: MusicStatus;
rating?: number;
notes?: string;
coverArt?: string;
tags?: string[];
links?: Link[];
interface CreateMusicDto {
title: string;
artist: string;
type: MusicType;
status: MusicStatus;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverArt?: string;
tags?: Array<string>;
links?: Array<Link>;
}
export interface UpdateMusicDto extends Partial<CreateMusicDto> {
interface UpdateMusicDto extends Partial<CreateMusicDto> {
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
}
export { MusicStatus, MusicType };
export type { CreateMusicDto, Music, UpdateMusicDto };
+28 -18
View File
@@ -1,51 +1,61 @@
/**
* @copyright 2026 NHCarrigan
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
export enum ShowType {
import type { Link } from "./common.types";
enum ShowType {
tvSeries = "TV_SERIES",
anime = "ANIME",
film = "FILM",
documentary = "DOCUMENTARY",
}
export enum ShowStatus {
enum ShowStatus {
watching = "WATCHING",
completed = "COMPLETED",
wantToWatch = "WANT_TO_WATCH",
retired = "RETIRED",
}
import { Link } from "./common.types";
export interface Show {
interface Show {
id: string;
title: string;
type: ShowType;
status: ShowStatus;
dateAdded: Date;
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags: string[];
links: Link[];
tags: Array<string>;
links: Array<Link>;
createdAt: Date;
updatedAt: Date;
}
export interface CreateShowDto {
title: string;
type: ShowType;
status: ShowStatus;
rating?: number;
notes?: string;
coverImage?: string;
tags?: string[];
links?: Link[];
interface CreateShowDto {
title: string;
type: ShowType;
status: ShowStatus;
dateStarted?: Date;
dateFinished?: Date;
rating?: number;
notes?: string;
coverImage?: string;
tags?: Array<string>;
links?: Array<Link>;
}
export interface UpdateShowDto extends Partial<CreateShowDto> {
interface UpdateShowDto extends Partial<CreateShowDto> {
dateStarted?: Date;
dateCompleted?: Date;
dateFinished?: Date;
}
export { ShowStatus, ShowType };
export type { CreateShowDto, Show, UpdateShowDto };
+106 -85
View File
@@ -1,104 +1,110 @@
import type { CreateGameDto } from "./game.types";
import type { CreateBookDto } from "./book.types";
import type { CreateMusicDto } from "./music.types";
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { CreateArtDto } from "./art.types";
import type { CreateShowDto } from "./show.types";
import type { CreateBookDto } from "./book.types";
import type { CreateGameDto } from "./game.types";
import type { CreateMangaDto } from "./manga.types";
import type { CreateMusicDto } from "./music.types";
import type { CreateShowDto } from "./show.types";
export enum SuggestionEntity {
GAME = "GAME",
BOOK = "BOOK",
MUSIC = "MUSIC",
ART = "ART",
SHOW = "SHOW",
MANGA = "MANGA",
enum SuggestionEntity {
game = "GAME",
book = "BOOK",
music = "MUSIC",
art = "ART",
show = "SHOW",
manga = "MANGA",
}
export enum SuggestionStatus {
UNREVIEWED = "UNREVIEWED",
ACCEPTED = "ACCEPTED",
DECLINED = "DECLINED",
enum SuggestionStatus {
unreviewed = "UNREVIEWED",
accepted = "ACCEPTED",
declined = "DECLINED",
}
export interface SuggestionUser {
id: string;
username: string;
avatar?: string;
interface SuggestionUser {
id: string;
username: string;
avatar?: string;
inDiscord: boolean;
isVip: boolean;
isMod: boolean;
isStaff: boolean;
isVip: boolean;
isMod: boolean;
isStaff: boolean;
}
export interface Suggestion {
id: string;
userId: string;
user: SuggestionUser;
entityType: SuggestionEntity;
status: SuggestionStatus;
interface Suggestion {
id: string;
userId: string;
user: SuggestionUser;
entityType: SuggestionEntity;
status: SuggestionStatus;
declineReason?: string;
title: string;
gameData?: Omit<CreateGameDto, "rating">;
bookData?: Omit<CreateBookDto, "rating">;
musicData?: Omit<CreateMusicDto, "rating">;
artData?: CreateArtDto;
showData?: Omit<CreateShowDto, "rating">;
mangaData?: Omit<CreateMangaDto, "rating">;
createdAt: Date;
updatedAt: Date;
title: string;
gameData?: Omit<CreateGameDto, "rating">;
bookData?: Omit<CreateBookDto, "rating">;
musicData?: Omit<CreateMusicDto, "rating">;
artData?: CreateArtDto;
showData?: Omit<CreateShowDto, "rating">;
mangaData?: Omit<CreateMangaDto, "rating">;
createdAt: Date;
updatedAt: Date;
}
export interface CreateGameSuggestionDto {
entityType: SuggestionEntity.GAME;
title: string;
platform?: string;
notes?: string;
interface CreateGameSuggestionDto {
entityType: SuggestionEntity.game;
title: string;
platform?: string;
notes?: string;
coverImage?: string;
}
export interface CreateBookSuggestionDto {
entityType: SuggestionEntity.BOOK;
title: string;
author: string;
isbn?: string;
notes?: string;
interface CreateBookSuggestionDto {
entityType: SuggestionEntity.book;
title: string;
author: string;
isbn?: string;
notes?: string;
coverImage?: string;
}
export interface CreateMusicSuggestionDto {
entityType: SuggestionEntity.MUSIC;
title: string;
artist: string;
type: string;
notes?: string;
coverArt?: string;
interface CreateMusicSuggestionDto {
entityType: SuggestionEntity.music;
title: string;
artist: string;
type: string;
notes?: string;
coverArt?: string;
}
export interface CreateArtSuggestionDto {
entityType: SuggestionEntity.ART;
title: string;
artist: string;
interface CreateArtSuggestionDto {
entityType: SuggestionEntity.art;
title: string;
artist: string;
description?: string;
imageUrl: string;
imageUrl: string;
}
export interface CreateShowSuggestionDto {
entityType: SuggestionEntity.SHOW;
title: string;
type: string;
notes?: string;
interface CreateShowSuggestionDto {
entityType: SuggestionEntity.show;
title: string;
type: string;
notes?: string;
coverImage?: string;
}
export interface CreateMangaSuggestionDto {
entityType: SuggestionEntity.MANGA;
title: string;
author: string;
notes?: string;
interface CreateMangaSuggestionDto {
entityType: SuggestionEntity.manga;
title: string;
author: string;
notes?: string;
coverImage?: string;
}
export type CreateSuggestionDto =
type CreateSuggestionDto =
| CreateGameSuggestionDto
| CreateBookSuggestionDto
| CreateMusicSuggestionDto
@@ -106,22 +112,37 @@ export type CreateSuggestionDto =
| CreateShowSuggestionDto
| CreateMangaSuggestionDto;
export interface DeclineSuggestionDto {
interface DeclineSuggestionDto {
reason?: string;
}
export interface AcceptWithEditsDto {
title?: string;
platform?: string;
author?: string;
artist?: string;
isbn?: string;
type?: string;
notes?: string;
interface AcceptWithEditsDto {
title?: string;
platform?: string;
author?: string;
artist?: string;
isbn?: string;
type?: string;
notes?: string;
description?: string;
coverImage?: string;
coverArt?: string;
imageUrl?: string;
tags?: string[];
links?: Array<{ label: string; url: string }>;
coverImage?: string;
coverArt?: string;
imageUrl?: string;
tags?: Array<string>;
links?: Array<{ label: string; url: string }>;
}
export { SuggestionEntity, SuggestionStatus };
export type {
AcceptWithEditsDto,
CreateArtSuggestionDto,
CreateBookSuggestionDto,
CreateGameSuggestionDto,
CreateMangaSuggestionDto,
CreateMusicSuggestionDto,
CreateShowSuggestionDto,
CreateSuggestionDto,
DeclineSuggestionDto,
Suggestion,
SuggestionUser,
};
+110
View File
@@ -0,0 +1,110 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Art, CreateArtDto, UpdateArtDto } from "../src/lib/art.types";
describe("art Types", () => {
describe("art interface", () => {
it("should accept valid art object with minimal fields", () => {
const art: Art = {
artist: "Jane Doe",
createdAt: new Date(),
dateAdded: new Date(),
id: "art123",
imageUrl: "https://example.com/sunset.jpg",
links: [],
tags: [],
title: "Beautiful Sunset",
updatedAt: new Date(),
};
expect(art.description).toBeUndefined();
expect(art.tags).toEqual([]);
});
it("should accept valid art object with all fields", () => {
const fullArt: Art = {
artist: "John Smith",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
description: "A breathtaking view of the mountains",
id: "art456",
imageUrl: "https://example.com/mountains.jpg",
links: [
{ title: "Artist Portfolio", url: "https://artist.com" },
{ title: "Instagram", url: "https://instagram.com/artist" },
],
tags: [ "landscape", "nature", "mountains" ],
title: "Mountain Landscape",
updatedAt: new Date("2024-01-02"),
};
expect(fullArt.description).toBe("A breathtaking view of the mountains");
expect(fullArt.tags).toHaveLength(3);
expect(fullArt.links).toHaveLength(2);
});
});
describe("createArtDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateArtDto = {
artist: "New Artist",
imageUrl: "https://example.com/art.jpg",
title: "New Artwork",
};
expect(createDto.description).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateArtDto = {
artist: "Famous Artist",
description: "Detailed description",
imageUrl: "https://example.com/complete.jpg",
links: [ { title: "Website", url: "https://website.com" } ],
tags: [ "tag1", "tag2" ],
title: "Complete Artwork",
};
expect(fullCreateDto.tags).toEqual([ "tag1", "tag2" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateArtDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateArtDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates", () => {
const partialUpdate: UpdateArtDto = {
tags: [ "new-tag" ],
title: "Updated Title",
};
expect(partialUpdate.title).toBe("Updated Title");
expect(partialUpdate.artist).toBeUndefined();
expect(partialUpdate.tags).toEqual([ "new-tag" ]);
});
it("should accept full update", () => {
const fullUpdate: UpdateArtDto = {
artist: "Different Artist",
description: "New description",
imageUrl: "https://example.com/new-image.jpg",
links: [ { title: "New Link", url: "https://newlink.com" } ],
tags: [ "updated", "tags" ],
title: "Completely New Title",
};
expect(fullUpdate.title).toBe("Completely New Title");
expect(fullUpdate.links).toHaveLength(1);
});
});
});
+129
View File
@@ -0,0 +1,129 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import {
AuditAction,
AuditCategory,
type AuditLog,
type AuditLogFilters,
type AuditLogUser,
} from "../src/lib/audit.types";
describe("audit Types", () => {
describe("auditAction enum", () => {
it("should have all expected action values", () => {
expect(AuditAction.login).toBe("LOGIN");
expect(AuditAction.logout).toBe("LOGOUT");
expect(AuditAction.loginFailed).toBe("LOGIN_FAILED");
expect(AuditAction.commentCreate).toBe("COMMENT_CREATE");
expect(AuditAction.commentUpdate).toBe("COMMENT_UPDATE");
expect(AuditAction.commentDelete).toBe("COMMENT_DELETE");
expect(AuditAction.entryCreate).toBe("ENTRY_CREATE");
expect(AuditAction.entryUpdate).toBe("ENTRY_UPDATE");
expect(AuditAction.entryDelete).toBe("ENTRY_DELETE");
expect(AuditAction.like).toBe("LIKE");
expect(AuditAction.unlike).toBe("UNLIKE");
expect(AuditAction.userBan).toBe("USER_BAN");
expect(AuditAction.userUnban).toBe("USER_UNBAN");
expect(AuditAction.rateLimitExceeded).toBe("RATE_LIMIT_EXCEEDED");
expect(AuditAction.csrfValidationFailed).toBe("CSRF_VALIDATION_FAILED");
expect(AuditAction.unauthorizedAccess).toBe("UNAUTHORIZED_ACCESS");
});
});
describe("auditCategory enum", () => {
it("should have all expected category values", () => {
expect(AuditCategory.auth).toBe("AUTH");
expect(AuditCategory.content).toBe("CONTENT");
expect(AuditCategory.admin).toBe("ADMIN");
expect(AuditCategory.security).toBe("SECURITY");
});
});
describe("auditLogUser interface", () => {
it("should accept valid user objects", () => {
const userWithAvatar: AuditLogUser = {
avatar: "https://example.com/avatar.png",
id: "user123",
username: "testuser",
};
const userWithoutAvatar: AuditLogUser = {
id: "user456",
username: "anotheruser",
};
expect(userWithAvatar.avatar).toBe("https://example.com/avatar.png");
expect(userWithoutAvatar.avatar).toBeUndefined();
});
});
describe("auditLog interface", () => {
it("should accept valid audit log with minimal fields", () => {
const minimalLog: AuditLog = {
action: AuditAction.login,
category: AuditCategory.auth,
createdAt: new Date(),
id: "log123",
success: true,
};
expect(minimalLog.userId).toBeUndefined();
expect(minimalLog.details).toBeUndefined();
});
it("should accept valid audit log with all fields", () => {
const fullLog: AuditLog = {
action: AuditAction.commentDelete,
category: AuditCategory.admin,
createdAt: new Date(),
details: "Admin deleted inappropriate comment",
id: "log456",
resourceId: "comment123",
resourceType: "comment",
success: true,
targetUser: {
id: "user789",
username: "targetuser",
},
targetUserId: "user789",
user: {
avatar: "https://example.com/avatar.png",
id: "user123",
username: "admin",
},
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64)",
userId: "user123",
};
expect(fullLog.user?.username).toBe("admin");
expect(fullLog.targetUser?.username).toBe("targetuser");
});
});
describe("auditLogFilters interface", () => {
it("should accept empty filters", () => {
const emptyFilters: AuditLogFilters = {};
expect(emptyFilters).toEqual({});
});
it("should accept filters with all fields", () => {
const fullFilters: AuditLogFilters = {
action: AuditAction.login,
category: AuditCategory.auth,
endDate: new Date("2024-12-31"),
limit: 50,
page: 1,
startDate: new Date("2024-01-01"),
success: true,
userId: "user123",
};
expect(fullFilters.page).toBe(1);
expect(fullFilters.limit).toBe(50);
});
});
});
+118
View File
@@ -0,0 +1,118 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { AuthResponse, JwtPayload, User } from "../src/lib/auth.types";
describe("auth Types", () => {
describe("user interface", () => {
it("should accept valid user objects", () => {
const userWithAvatar: User = {
avatar: "https://example.com/avatar.png",
discordId: "discord123",
email: "test@example.com",
id: "user123",
inDiscord: true,
isAdmin: true,
isBanned: false,
isMod: true,
isStaff: true,
isVip: false,
username: "testuser",
};
const userWithoutAvatar: User = {
discordId: "discord456",
email: "another@example.com",
id: "user456",
inDiscord: false,
isAdmin: false,
isBanned: false,
isMod: false,
isStaff: false,
isVip: true,
username: "anotheruser",
};
expect(userWithAvatar.avatar).toBe("https://example.com/avatar.png");
expect(userWithoutAvatar.avatar).toBeUndefined();
expect(userWithAvatar.isAdmin).toBeTruthy();
expect(userWithoutAvatar.isVip).toBeTruthy();
});
it("should handle all boolean flags correctly", () => {
const bannedUser: User = {
discordId: "discord789",
email: "banned@example.com",
id: "banned123",
inDiscord: false,
isAdmin: false,
isBanned: true,
isMod: false,
isStaff: false,
isVip: false,
username: "banneduser",
};
expect(bannedUser.isBanned).toBeTruthy();
expect(bannedUser.isAdmin).toBeFalsy();
expect(bannedUser.inDiscord).toBeFalsy();
});
});
describe("jwtPayload interface", () => {
it("should accept JWT payload with required fields", () => {
const payload: JwtPayload = {
email: "test@example.com",
isAdmin: false,
sub: "user123",
username: "testuser",
};
expect(payload.sub).toBe("user123");
expect(payload.iat).toBeUndefined();
expect(payload.exp).toBeUndefined();
});
it("should accept JWT payload with timestamps", () => {
const now = Math.floor(Date.now() / 1000);
const payloadWithTimestamps: JwtPayload = {
email: "another@example.com",
exp: now + 3600,
iat: now,
isAdmin: true,
sub: "user456",
username: "anotheruser", // 1 hour
};
expect(payloadWithTimestamps.iat).toBe(now);
expect(payloadWithTimestamps.exp).toBe(now + 3600);
});
});
describe("authResponse interface", () => {
it("should accept valid auth response", () => {
const authResponse: AuthResponse = {
accessToken: "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
user: {
avatar: "https://example.com/avatar.png",
discordId: "discord123",
email: "test@example.com",
id: "user123",
inDiscord: true,
isAdmin: false,
isBanned: false,
isMod: false,
isStaff: false,
isVip: false,
username: "testuser",
},
};
expect(authResponse.accessToken).toContain("eyJ");
expect(authResponse.user.username).toBe("testuser");
});
});
});
+151
View File
@@ -0,0 +1,151 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { BookStatus } from "../src/lib/book.types";
import type { Book, CreateBookDto, UpdateBookDto } from "../src/lib/book.types";
describe("book Types", () => {
describe("bookStatus enum", () => {
it("should have the correct values", () => {
expect(BookStatus.reading).toBe("READING");
expect(BookStatus.finished).toBe("FINISHED");
expect(BookStatus.toRead).toBe("TO_READ");
});
it("should have all expected enum values", () => {
const values = Object.values(BookStatus);
expect(values).toHaveLength(4);
expect(values).toContain("READING");
expect(values).toContain("FINISHED");
expect(values).toContain("TO_READ");
expect(values).toContain("RETIRED");
});
});
describe("book interface", () => {
it("should accept valid book object with minimal fields", () => {
const book: Book = {
author: "F. Scott Fitzgerald",
createdAt: new Date("2024-01-15"),
dateAdded: new Date("2024-01-15"),
id: "book123",
links: [],
status: BookStatus.reading,
tags: [],
title: "The Great Gatsby",
updatedAt: new Date("2024-01-16"),
};
expect(book.isbn).toBeUndefined();
expect(book.dateFinished).toBeUndefined();
expect(book.rating).toBeUndefined();
expect(book.notes).toBeUndefined();
expect(book.coverImage).toBeUndefined();
});
it("should accept valid book object with all fields", () => {
const fullBook: Book = {
author: "George Orwell",
coverImage: "https://example.com/1984-cover.jpg",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
dateFinished: new Date("2024-01-20"),
id: "book456",
isbn: "978-0-452-28423-4",
links: [
{ title: "Goodreads", url: "https://goodreads.com/book/1984" },
{ title: "Wikipedia", url: "https://wikipedia.org/wiki/1984" },
],
notes: "A dystopian masterpiece that remains relevant",
rating: 5,
status: BookStatus.finished,
tags: [ "dystopian", "classic", "political" ],
title: "1984",
updatedAt: new Date("2024-01-20"),
};
expect(fullBook.isbn).toBe("978-0-452-28423-4");
expect(fullBook.rating).toBe(5);
expect(fullBook.tags).toHaveLength(3);
expect(fullBook.links).toHaveLength(2);
});
});
describe("createBookDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateBookDto = {
author: "Harper Lee",
status: BookStatus.toRead,
title: "To Kill a Mockingbird",
};
expect(createDto.isbn).toBeUndefined();
expect(createDto.rating).toBeUndefined();
expect(createDto.notes).toBeUndefined();
expect(createDto.coverImage).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateBookDto = {
author: "J.R.R. Tolkien",
coverImage: "https://example.com/hobbit-cover.jpg",
isbn: "978-0-547-92822-7",
links: [ { title: "Author Website", url: "https://tolkien.com" } ],
notes: "Starting the journey to Middle-earth",
rating: 4,
status: BookStatus.reading,
tags: [ "fantasy", "adventure", "classic" ],
title: "The Hobbit",
};
expect(fullCreateDto.isbn).toBe("978-0-547-92822-7");
expect(fullCreateDto.rating).toBe(4);
expect(fullCreateDto.tags).toEqual([ "fantasy", "adventure", "classic" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateBookDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateBookDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates including dateFinished", () => {
const partialUpdate: UpdateBookDto = {
dateFinished: new Date("2024-02-01"),
rating: 5,
status: BookStatus.finished,
};
expect(partialUpdate.status).toBe(BookStatus.finished);
expect(partialUpdate.dateFinished).toEqual(new Date("2024-02-01"));
expect(partialUpdate.rating).toBe(5);
expect(partialUpdate.title).toBeUndefined();
});
it("should accept full update", () => {
const fullUpdate: UpdateBookDto = {
author: "Different Author",
coverImage: "https://example.com/new-cover.jpg",
dateFinished: new Date("2024-02-15"),
isbn: "978-1-234-56789-0",
links: [ { title: "New Link", url: "https://newlink.com" } ],
notes: "Updated notes after finishing",
rating: 3,
status: BookStatus.finished,
tags: [ "updated", "tags" ],
title: "Updated Title",
};
expect(fullUpdate.title).toBe("Updated Title");
expect(fullUpdate.dateFinished).toEqual(new Date("2024-02-15"));
expect(fullUpdate.links).toHaveLength(1);
});
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* @copyright 2026 NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { CommentUser, Comment, CreateCommentDto } from "../src/lib/comment.types";
describe("comment Types", () => {
describe("commentUser interface", () => {
it("should accept valid user object with minimal fields", () => {
const user: CommentUser = {
id: "user123",
username: "testuser",
};
expect(user.avatar).toBeUndefined();
expect(user.inDiscord).toBeUndefined();
expect(user.isVip).toBeUndefined();
expect(user.isMod).toBeUndefined();
expect(user.isStaff).toBeUndefined();
});
it("should accept valid user object with all fields", () => {
const fullUser: CommentUser = {
avatar: "https://example.com/avatar.png",
id: "user456",
inDiscord: true,
isMod: true,
isStaff: false,
isVip: true,
username: "vipmoduser",
};
expect(fullUser.avatar).toBe("https://example.com/avatar.png");
expect(fullUser.inDiscord).toBeTruthy();
expect(fullUser.isVip).toBeTruthy();
expect(fullUser.isMod).toBeTruthy();
expect(fullUser.isStaff).toBeFalsy();
});
});
describe("comment interface", () => {
it("should accept valid comment object with minimal fields", () => {
const comment: Comment = {
content: "This is a great book!",
createdAt: new Date("2024-01-15"),
id: "comment123",
updatedAt: new Date("2024-01-15"),
user: {
id: "user123",
username: "bookworm",
},
userId: "user123",
};
expect(comment.rawContent).toBeUndefined();
expect(comment.gameId).toBeUndefined();
expect(comment.bookId).toBeUndefined();
expect(comment.musicId).toBeUndefined();
expect(comment.artId).toBeUndefined();
expect(comment.showId).toBeUndefined();
expect(comment.mangaId).toBeUndefined();
});
it("should accept comment for a book", () => {
const bookComment: Comment = {
bookId: "book123",
content: "<p>Amazing read!</p>",
createdAt: new Date("2024-01-20"),
id: "comment456",
rawContent: "Amazing read!",
updatedAt: new Date("2024-01-21"),
user: {
avatar: "https://example.com/reader.png",
id: "user789",
inDiscord: true,
isMod: false,
isStaff: false,
isVip: false,
username: "reader",
},
userId: "user789",
};
expect(bookComment.bookId).toBe("book123");
expect(bookComment.rawContent).toBe("Amazing read!");
expect(bookComment.gameId).toBeUndefined();
});
it("should accept comment for a game", () => {
const gameComment: Comment = {
content: "Best game ever!",
createdAt: new Date("2024-02-01"),
gameId: "game789",
id: "comment789",
updatedAt: new Date("2024-02-01"),
user: {
id: "user456",
username: "gamer",
},
userId: "user456",
};
expect(gameComment.gameId).toBe("game789");
expect(gameComment.bookId).toBeUndefined();
});
it("should accept comment for music", () => {
const musicComment: Comment = {
content: "Beautiful album",
createdAt: new Date("2024-02-10"),
id: "comment999",
musicId: "music123",
updatedAt: new Date("2024-02-10"),
user: {
id: "user111",
username: "musiclover",
},
userId: "user111",
};
expect(musicComment.musicId).toBe("music123");
});
it("should accept comment for art", () => {
const artComment: Comment = {
artId: "art456",
content: "Stunning artwork!",
createdAt: new Date("2024-02-15"),
id: "comment111",
updatedAt: new Date("2024-02-15"),
user: {
id: "user222",
username: "artcritic",
},
userId: "user222",
};
expect(artComment.artId).toBe("art456");
});
it("should accept comment for a show", () => {
const showComment: Comment = {
content: "Great series!",
createdAt: new Date("2024-02-20"),
id: "comment222",
showId: "show789",
updatedAt: new Date("2024-02-20"),
user: {
id: "user333",
username: "tvfan",
},
userId: "user333",
};
expect(showComment.showId).toBe("show789");
});
it("should accept comment for manga", () => {
const mangaComment: Comment = {
content: "Awesome manga!",
createdAt: new Date("2024-02-25"),
id: "comment333",
mangaId: "manga123",
updatedAt: new Date("2024-02-25"),
user: {
id: "user444",
username: "mangareader",
},
userId: "user444",
};
expect(mangaComment.mangaId).toBe("manga123");
});
});
describe("createCommentDto interface", () => {
it("should accept DTO with content", () => {
const createDto: CreateCommentDto = {
content: "This is my comment",
};
expect(createDto.content).toBe("This is my comment");
});
it("should accept DTO with formatted content", () => {
const createDto: CreateCommentDto = {
content: "<p>This is <strong>formatted</strong> content!</p>",
};
expect(createDto.content).toBe("<p>This is <strong>formatted</strong> content!</p>");
});
});
});
+37
View File
@@ -0,0 +1,37 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Link } from "../src/lib/common.types";
describe("common Types", () => {
describe("link interface", () => {
it("should accept valid Link objects", () => {
const validLink: Link = {
title: "Example Website",
url: "https://example.com",
};
expect(validLink.title).toBe("Example Website");
expect(validLink.url).toBe("https://example.com");
});
it("should work with different URL formats", () => {
const links: Array<Link> = [
{ title: "HTTP URL", url: "http://example.com" },
{ title: "HTTPS URL", url: "https://example.com" },
{ title: "Relative URL", url: "/path/to/page" },
{ title: "URL with query", url: "https://example.com?query=value" },
{ title: "URL with anchor", url: "https://example.com#section" },
];
expect(links).toHaveLength(5);
for (const link of links) {
expect(link).toHaveProperty("title");
expect(link).toHaveProperty("url");
}
});
});
});
+176
View File
@@ -0,0 +1,176 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { GameStatus } from "../src/lib/game.types";
import type { Game, CreateGameDto, UpdateGameDto } from "../src/lib/game.types";
describe("game Types", () => {
describe("gameStatus enum", () => {
it("should have the correct values", () => {
expect(GameStatus.playing).toBe("PLAYING");
expect(GameStatus.completed).toBe("COMPLETED");
expect(GameStatus.backlog).toBe("BACKLOG");
});
it("should have all expected enum values", () => {
const values = Object.values(GameStatus);
expect(values).toHaveLength(4);
expect(values).toContain("PLAYING");
expect(values).toContain("COMPLETED");
expect(values).toContain("BACKLOG");
expect(values).toContain("RETIRED");
});
});
describe("game interface", () => {
it("should accept valid game object with minimal fields", () => {
const game: Game = {
createdAt: new Date("2024-01-15"),
dateAdded: new Date("2024-01-15"),
id: "game123",
links: [],
status: GameStatus.playing,
tags: [],
title: "The Legend of Zelda: Breath of the Wild",
updatedAt: new Date("2024-01-16"),
};
expect(game.platform).toBeUndefined();
expect(game.dateCompleted).toBeUndefined();
expect(game.rating).toBeUndefined();
expect(game.notes).toBeUndefined();
expect(game.coverImage).toBeUndefined();
});
it("should accept valid game object with all fields", () => {
const fullGame: Game = {
coverImage: "https://example.com/hades-cover.jpg",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
dateCompleted: new Date("2024-01-20"),
id: "game456",
links: [
{ title: "Steam", url: "https://store.steampowered.com/app/hades" },
{ title: "Official Site", url: "https://supergiantgames.com/hades" },
],
notes: "One of the best roguelikes ever made",
platform: "Nintendo Switch",
rating: 5,
status: GameStatus.completed,
tags: [ "roguelike", "indie", "action", "mythology" ],
title: "Hades",
updatedAt: new Date("2024-01-20"),
};
expect(fullGame.platform).toBe("Nintendo Switch");
expect(fullGame.rating).toBe(5);
expect(fullGame.tags).toHaveLength(4);
expect(fullGame.links).toHaveLength(2);
});
it("should accept games on different platforms", () => {
const pcGame: Game = {
createdAt: new Date("2024-02-01"),
dateAdded: new Date("2024-02-01"),
id: "game789",
links: [],
platform: "PC",
status: GameStatus.completed,
tags: [ "puzzle", "first-person" ],
title: "Portal 2",
updatedAt: new Date("2024-02-01"),
};
const ps5Game: Game = {
createdAt: new Date("2024-02-05"),
dateAdded: new Date("2024-02-05"),
id: "game999",
links: [],
platform: "PlayStation 5",
status: GameStatus.backlog,
tags: [ "action", "superhero" ],
title: "Spider-Man: Miles Morales",
updatedAt: new Date("2024-02-05"),
};
expect(pcGame.platform).toBe("PC");
expect(ps5Game.platform).toBe("PlayStation 5");
});
});
describe("createGameDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateGameDto = {
status: GameStatus.backlog,
title: "Elden Ring",
};
expect(createDto.platform).toBeUndefined();
expect(createDto.rating).toBeUndefined();
expect(createDto.notes).toBeUndefined();
expect(createDto.coverImage).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateGameDto = {
coverImage: "https://example.com/hollow-knight.jpg",
links: [ { title: "Wiki", url: "https://hollowknight.wiki" } ],
notes: "Beautiful metroidvania with challenging gameplay",
platform: "Nintendo Switch",
rating: 4,
status: GameStatus.playing,
tags: [ "metroidvania", "indie", "platformer" ],
title: "Hollow Knight",
};
expect(fullCreateDto.platform).toBe("Nintendo Switch");
expect(fullCreateDto.rating).toBe(4);
expect(fullCreateDto.tags).toEqual([ "metroidvania", "indie", "platformer" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateGameDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateGameDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates including dateCompleted", () => {
const partialUpdate: UpdateGameDto = {
dateCompleted: new Date("2024-02-10"),
rating: 5,
status: GameStatus.completed,
};
expect(partialUpdate.status).toBe(GameStatus.completed);
expect(partialUpdate.dateCompleted).toEqual(new Date("2024-02-10"));
expect(partialUpdate.rating).toBe(5);
expect(partialUpdate.title).toBeUndefined();
});
it("should accept full update", () => {
const fullUpdate: UpdateGameDto = {
coverImage: "https://example.com/new-cover.jpg",
dateCompleted: new Date("2024-02-15"),
links: [ { title: "New Link", url: "https://newlink.com" } ],
notes: "Updated after completion",
platform: "Xbox Series X",
rating: 3,
status: GameStatus.completed,
tags: [ "updated", "tags" ],
title: "Updated Game Title",
};
expect(fullUpdate.title).toBe("Updated Game Title");
expect(fullUpdate.platform).toBe("Xbox Series X");
expect(fullUpdate.dateCompleted).toEqual(new Date("2024-02-15"));
expect(fullUpdate.links).toHaveLength(1);
});
});
});
+228
View File
@@ -0,0 +1,228 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import type { Like, CreateLikeDto, LikeResponse, LikedItemDto, LikeCountDto } from "../src/lib/like.types";
describe("like Types", () => {
describe("like interface", () => {
it("should accept valid like object for a book", () => {
const bookLike: Like = {
createdAt: new Date("2024-01-15"),
entityId: "book456",
entityType: "book",
id: "like123",
userId: "user123",
};
expect(bookLike.entityType).toBe("book");
expect(bookLike.entityId).toBe("book456");
});
it("should accept valid like object for a game", () => {
const gameLike: Like = {
createdAt: new Date("2024-01-20"),
entityId: "game123",
entityType: "game",
id: "like456",
userId: "user789",
};
expect(gameLike.entityType).toBe("game");
expect(gameLike.entityId).toBe("game123");
});
it("should accept likes for all entity types", () => {
const showLike: Like = {
createdAt: new Date("2024-02-01"),
entityId: "show222",
entityType: "show",
id: "like789",
userId: "user111",
};
const mangaLike: Like = {
createdAt: new Date("2024-02-05"),
entityId: "manga444",
entityType: "manga",
id: "like999",
userId: "user333",
};
const musicLike: Like = {
createdAt: new Date("2024-02-10"),
entityId: "music666",
entityType: "music",
id: "like111",
userId: "user555",
};
const artLike: Like = {
createdAt: new Date("2024-02-15"),
entityId: "art888",
entityType: "art",
id: "like222",
userId: "user777",
};
expect(showLike.entityType).toBe("show");
expect(mangaLike.entityType).toBe("manga");
expect(musicLike.entityType).toBe("music");
expect(artLike.entityType).toBe("art");
});
});
describe("createLikeDto type", () => {
it("should pick only entityType and entityId from Like", () => {
const createDto: CreateLikeDto = {
entityId: "book123",
entityType: "book",
};
expect(createDto.entityType).toBe("book");
expect(createDto.entityId).toBe("book123");
// Verify it only has these two properties
expect(Object.keys(createDto)).toHaveLength(2);
});
it("should accept different entity types", () => {
const gameDto: CreateLikeDto = {
entityId: "game456",
entityType: "game",
};
const artDto: CreateLikeDto = {
entityId: "art789",
entityType: "art",
};
expect(gameDto.entityType).toBe("game");
expect(artDto.entityType).toBe("art");
});
});
describe("likeCountDto interface", () => {
it("should accept valid like count object", () => {
const likeCount: LikeCountDto = {
count: 42,
entityId: "book123",
entityType: "book",
};
expect(likeCount.count).toBe(42);
expect(likeCount.entityType).toBe("book");
});
it("should accept like counts for different entities", () => {
const gameLikeCount: LikeCountDto = {
count: 15,
entityId: "game456",
entityType: "game",
};
const showLikeCount: LikeCountDto = {
count: 0,
entityId: "show789",
entityType: "show",
};
expect(gameLikeCount.count).toBe(15);
expect(showLikeCount.count).toBe(0);
});
});
describe("likedItemDto interface", () => {
it("should accept liked item with unknown item type", () => {
const likedBook: LikedItemDto = {
item: {
author: "F. Scott Fitzgerald",
id: "book456",
title: "The Great Gatsby",
// ... other book properties
},
like: {
createdAt: new Date("2024-01-15"),
entityId: "book456",
entityType: "book",
id: "like123",
userId: "user123",
},
};
expect(likedBook.like.entityType).toBe("book");
expect(likedBook.item).toBeDefined();
});
it("should accept liked items for different entity types", () => {
const likedGame: LikedItemDto = {
item: {
id: "game123",
platform: "Nintendo Switch",
title: "Hades",
// ... other game properties
},
like: {
createdAt: new Date("2024-02-01"),
entityId: "game123",
entityType: "game",
id: "like456",
userId: "user789",
},
};
const likedArt: LikedItemDto = {
item: {
artist: "Jane Doe",
id: "art456",
imageUrl: "https://example.com/sunset.jpg",
title: "Beautiful Sunset",
// ... other art properties
},
like: {
createdAt: new Date("2024-02-10"),
entityId: "art456",
entityType: "art",
id: "like789",
userId: "user999",
},
};
expect(likedGame.like.entityType).toBe("game");
expect(likedArt.like.entityType).toBe("art");
});
});
describe("likeResponse interface", () => {
it("should accept response with liked true", () => {
const likedResponse: LikeResponse = {
count: 10,
liked: true,
};
expect(likedResponse.liked).toBeTruthy();
expect(likedResponse.count).toBe(10);
});
it("should accept response with liked false", () => {
const notLikedResponse: LikeResponse = {
count: 5,
liked: false,
};
expect(notLikedResponse.liked).toBeFalsy();
expect(notLikedResponse.count).toBe(5);
});
it("should accept response with zero count", () => {
const zeroResponse: LikeResponse = {
count: 0,
liked: false,
};
expect(zeroResponse.liked).toBeFalsy();
expect(zeroResponse.count).toBe(0);
});
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MangaStatus } from "../src/lib/manga.types";
import type { Manga, CreateMangaDto, UpdateMangaDto } from "../src/lib/manga.types";
describe("manga Types", () => {
describe("mangaStatus enum", () => {
it("should have the correct values", () => {
expect(MangaStatus.reading).toBe("READING");
expect(MangaStatus.completed).toBe("COMPLETED");
expect(MangaStatus.wantToRead).toBe("WANT_TO_READ");
});
it("should have all expected enum values", () => {
const values = Object.values(MangaStatus);
expect(values).toHaveLength(4);
expect(values).toContain("READING");
expect(values).toContain("COMPLETED");
expect(values).toContain("WANT_TO_READ");
expect(values).toContain("RETIRED");
});
});
describe("manga interface", () => {
it("should accept valid manga object with minimal fields", () => {
const manga: Manga = {
author: "Tsugumi Ohba",
createdAt: new Date("2024-01-15"),
dateAdded: new Date("2024-01-15"),
id: "manga123",
links: [],
status: MangaStatus.reading,
tags: [],
title: "Death Note",
updatedAt: new Date("2024-01-16"),
};
expect(manga.dateCompleted).toBeUndefined();
expect(manga.rating).toBeUndefined();
expect(manga.notes).toBeUndefined();
expect(manga.coverImage).toBeUndefined();
});
it("should accept valid manga object with all fields", () => {
const fullManga: Manga = {
author: "Eiichiro Oda",
coverImage: "https://example.com/onepiece-cover.jpg",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
dateCompleted: undefined,
id: "manga456",
links: [
{ title: "MyAnimeList", url: "https://myanimelist.net/manga/13" },
{ title: "Official Site", url: "https://one-piece.com" },
],
notes: "Epic adventure that keeps getting better",
// Still ongoing
rating: 5,
status: MangaStatus.reading,
tags: [ "adventure", "shounen", "pirates", "long-running" ],
title: "One Piece",
updatedAt: new Date("2024-02-01"),
};
expect(fullManga.author).toBe("Eiichiro Oda");
expect(fullManga.rating).toBe(5);
expect(fullManga.tags).toHaveLength(4);
expect(fullManga.links).toHaveLength(2);
});
it("should accept completed manga", () => {
const completedManga: Manga = {
author: "Hiromu Arakawa",
coverImage: "https://example.com/fma-cover.jpg",
createdAt: new Date("2023-12-01"),
dateAdded: new Date("2023-12-01"),
dateCompleted: new Date("2024-01-30"),
id: "manga789",
links: [],
notes: "Perfect from start to finish",
rating: 5,
status: MangaStatus.completed,
tags: [ "shounen", "adventure", "alchemy" ],
title: "Fullmetal Alchemist",
updatedAt: new Date("2024-01-30"),
};
expect(completedManga.status).toBe(MangaStatus.completed);
expect(completedManga.dateCompleted).toEqual(new Date("2024-01-30"));
});
});
describe("createMangaDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateMangaDto = {
author: "Hajime Isayama",
status: MangaStatus.wantToRead,
title: "Attack on Titan",
};
expect(createDto.rating).toBeUndefined();
expect(createDto.notes).toBeUndefined();
expect(createDto.coverImage).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateMangaDto = {
author: "Kohei Horikoshi",
coverImage: "https://example.com/mha-cover.jpg",
links: [ { title: "Wiki", url: "https://mha.wiki" } ],
notes: "Great superhero manga with unique powers",
rating: 4,
status: MangaStatus.reading,
tags: [ "shounen", "superhero", "school" ],
title: "My Hero Academia",
};
expect(fullCreateDto.author).toBe("Kohei Horikoshi");
expect(fullCreateDto.rating).toBe(4);
expect(fullCreateDto.tags).toEqual([ "shounen", "superhero", "school" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateMangaDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateMangaDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates including dateCompleted", () => {
const partialUpdate: UpdateMangaDto = {
dateCompleted: new Date("2024-02-10"),
rating: 4,
status: MangaStatus.completed,
};
expect(partialUpdate.status).toBe(MangaStatus.completed);
expect(partialUpdate.dateCompleted).toEqual(new Date("2024-02-10"));
expect(partialUpdate.rating).toBe(4);
expect(partialUpdate.title).toBeUndefined();
});
it("should accept full update", () => {
const fullUpdate: UpdateMangaDto = {
author: "Different Author",
coverImage: "https://example.com/new-manga-cover.jpg",
dateCompleted: new Date("2024-02-15"),
links: [ { title: "New Link", url: "https://newlink.com" } ],
notes: "Updated after finishing the series",
rating: 3,
status: MangaStatus.completed,
tags: [ "updated", "tags" ],
title: "Updated Manga Title",
};
expect(fullUpdate.title).toBe("Updated Manga Title");
expect(fullUpdate.author).toBe("Different Author");
expect(fullUpdate.dateCompleted).toEqual(new Date("2024-02-15"));
expect(fullUpdate.links).toHaveLength(1);
});
});
});
+201
View File
@@ -0,0 +1,201 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { MusicStatus, MusicType } from "../src/lib/music.types";
import type { Music, CreateMusicDto, UpdateMusicDto } from "../src/lib/music.types";
describe("music Types", () => {
describe("musicType enum", () => {
it("should have the correct values", () => {
expect(MusicType.album).toBe("ALBUM");
expect(MusicType.single).toBe("SINGLE");
expect(MusicType.ep).toBe("EP");
});
it("should have all expected enum values", () => {
const values = Object.values(MusicType);
expect(values).toHaveLength(3);
expect(values).toContain("ALBUM");
expect(values).toContain("SINGLE");
expect(values).toContain("EP");
});
});
describe("musicStatus enum", () => {
it("should have the correct values", () => {
expect(MusicStatus.listening).toBe("LISTENING");
expect(MusicStatus.completed).toBe("COMPLETED");
expect(MusicStatus.wantToListen).toBe("WANT_TO_LISTEN");
});
it("should have all expected enum values", () => {
const values = Object.values(MusicStatus);
expect(values).toHaveLength(4);
expect(values).toContain("LISTENING");
expect(values).toContain("COMPLETED");
expect(values).toContain("WANT_TO_LISTEN");
expect(values).toContain("RETIRED");
});
});
describe("music interface", () => {
it("should accept valid music object with minimal fields", () => {
const music: Music = {
artist: "Pink Floyd",
createdAt: new Date("2024-01-15"),
dateAdded: new Date("2024-01-15"),
id: "music123",
links: [],
status: MusicStatus.listening,
tags: [],
title: "Dark Side of the Moon",
type: MusicType.album,
updatedAt: new Date("2024-01-16"),
};
expect(music.dateCompleted).toBeUndefined();
expect(music.rating).toBeUndefined();
expect(music.notes).toBeUndefined();
expect(music.coverArt).toBeUndefined();
});
it("should accept valid music object with all fields", () => {
const fullMusic: Music = {
artist: "Pink Floyd",
coverArt: "https://example.com/the-wall-cover.jpg",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
dateCompleted: new Date("2024-01-20"),
id: "music456",
links: [
{ title: "Spotify", url: "https://spotify.com/album/the-wall" },
{ title: "Apple Music", url: "https://music.apple.com/album/the-wall" },
],
notes: "A rock opera masterpiece",
rating: 5,
status: MusicStatus.completed,
tags: [ "progressive rock", "concept album", "classic" ],
title: "The Wall",
type: MusicType.album,
updatedAt: new Date("2024-01-20"),
};
expect(fullMusic.artist).toBe("Pink Floyd");
expect(fullMusic.rating).toBe(5);
expect(fullMusic.tags).toHaveLength(3);
expect(fullMusic.links).toHaveLength(2);
});
it("should accept different music types", () => {
const single: Music = {
artist: "The Weeknd",
createdAt: new Date("2024-02-01"),
dateAdded: new Date("2024-02-01"),
id: "music789",
links: [],
status: MusicStatus.completed,
tags: [ "pop", "synthwave" ],
title: "Blinding Lights",
type: MusicType.single,
updatedAt: new Date("2024-02-01"),
};
const ep: Music = {
artist: "The Weeknd",
createdAt: new Date("2024-02-05"),
dateAdded: new Date("2024-02-05"),
id: "music999",
links: [],
status: MusicStatus.wantToListen,
tags: [ "r&b", "dark" ],
title: "My Dear Melancholy,",
type: MusicType.ep,
updatedAt: new Date("2024-02-05"),
};
expect(single.type).toBe(MusicType.single);
expect(ep.type).toBe(MusicType.ep);
});
});
describe("createMusicDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateMusicDto = {
artist: "Fleetwood Mac",
status: MusicStatus.wantToListen,
title: "Rumours",
type: MusicType.album,
};
expect(createDto.rating).toBeUndefined();
expect(createDto.notes).toBeUndefined();
expect(createDto.coverArt).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateMusicDto = {
artist: "The Weeknd",
coverArt: "https://example.com/after-hours.jpg",
links: [ { title: "YouTube", url: "https://youtube.com/album/after-hours" } ],
notes: "Dark synthwave vibes",
rating: 4,
status: MusicStatus.listening,
tags: [ "synthwave", "pop", "r&b" ],
title: "After Hours",
type: MusicType.album,
};
expect(fullCreateDto.artist).toBe("The Weeknd");
expect(fullCreateDto.type).toBe(MusicType.album);
expect(fullCreateDto.rating).toBe(4);
expect(fullCreateDto.tags).toEqual([ "synthwave", "pop", "r&b" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateMusicDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateMusicDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates including dateCompleted", () => {
const partialUpdate: UpdateMusicDto = {
dateCompleted: new Date("2024-02-10"),
rating: 5,
status: MusicStatus.completed,
};
expect(partialUpdate.status).toBe(MusicStatus.completed);
expect(partialUpdate.dateCompleted).toEqual(new Date("2024-02-10"));
expect(partialUpdate.rating).toBe(5);
expect(partialUpdate.title).toBeUndefined();
});
it("should accept full update", () => {
const fullUpdate: UpdateMusicDto = {
artist: "Different Artist",
coverArt: "https://example.com/new-cover.jpg",
dateCompleted: new Date("2024-02-15"),
links: [ { title: "New Link", url: "https://newlink.com" } ],
notes: "Updated after listening",
rating: 3,
status: MusicStatus.completed,
tags: [ "updated", "tags" ],
title: "Updated Album Title",
type: MusicType.ep,
};
expect(fullUpdate.title).toBe("Updated Album Title");
expect(fullUpdate.artist).toBe("Different Artist");
expect(fullUpdate.type).toBe(MusicType.ep);
expect(fullUpdate.dateCompleted).toEqual(new Date("2024-02-15"));
expect(fullUpdate.links).toHaveLength(1);
});
});
});
+195
View File
@@ -0,0 +1,195 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { ShowStatus, ShowType } from "../src/lib/show.types";
import type { Show, CreateShowDto, UpdateShowDto } from "../src/lib/show.types";
describe("show Types", () => {
describe("showType enum", () => {
it("should have the correct values", () => {
expect(ShowType.tvSeries).toBe("TV_SERIES");
expect(ShowType.anime).toBe("ANIME");
expect(ShowType.film).toBe("FILM");
expect(ShowType.documentary).toBe("DOCUMENTARY");
});
it("should have all expected enum values", () => {
const values = Object.values(ShowType);
expect(values).toHaveLength(4);
expect(values).toContain("TV_SERIES");
expect(values).toContain("ANIME");
expect(values).toContain("FILM");
expect(values).toContain("DOCUMENTARY");
});
});
describe("showStatus enum", () => {
it("should have the correct values", () => {
expect(ShowStatus.watching).toBe("WATCHING");
expect(ShowStatus.completed).toBe("COMPLETED");
expect(ShowStatus.wantToWatch).toBe("WANT_TO_WATCH");
});
it("should have all expected enum values", () => {
const values = Object.values(ShowStatus);
expect(values).toHaveLength(4);
expect(values).toContain("WATCHING");
expect(values).toContain("COMPLETED");
expect(values).toContain("WANT_TO_WATCH");
expect(values).toContain("RETIRED");
});
});
describe("show interface", () => {
it("should accept valid show object with minimal fields", () => {
const show: Show = {
createdAt: new Date("2024-01-15"),
dateAdded: new Date("2024-01-15"),
id: "show123",
links: [],
status: ShowStatus.watching,
tags: [],
title: "Breaking Bad",
type: ShowType.tvSeries,
updatedAt: new Date("2024-01-16"),
};
expect(show.dateCompleted).toBeUndefined();
expect(show.rating).toBeUndefined();
expect(show.notes).toBeUndefined();
expect(show.coverImage).toBeUndefined();
});
it("should accept valid show object with all fields", () => {
const fullShow: Show = {
coverImage: "https://example.com/aot-cover.jpg",
createdAt: new Date("2024-01-01"),
dateAdded: new Date("2024-01-01"),
dateCompleted: new Date("2024-01-20"),
id: "show456",
links: [
{ title: "MyAnimeList", url: "https://myanimelist.net/anime/16498" },
{ title: "Crunchyroll", url: "https://crunchyroll.com/attack-on-titan" },
],
notes: "Incredible story with amazing animation",
rating: 5,
status: ShowStatus.completed,
tags: [ "action", "dark fantasy", "shounen" ],
title: "Attack on Titan",
type: ShowType.anime,
updatedAt: new Date("2024-01-20"),
};
expect(fullShow.type).toBe(ShowType.anime);
expect(fullShow.rating).toBe(5);
expect(fullShow.tags).toHaveLength(3);
expect(fullShow.links).toHaveLength(2);
});
it("should accept different show types", () => {
const film: Show = {
createdAt: new Date("2024-02-01"),
dateAdded: new Date("2024-02-01"),
id: "show789",
links: [],
rating: 5,
status: ShowStatus.completed,
tags: [ "sci-fi", "thriller" ],
title: "Inception",
type: ShowType.film,
updatedAt: new Date("2024-02-01"),
};
const documentary: Show = {
createdAt: new Date("2024-02-05"),
dateAdded: new Date("2024-02-05"),
id: "show999",
links: [],
status: ShowStatus.wantToWatch,
tags: [ "nature", "wildlife" ],
title: "Planet Earth II",
type: ShowType.documentary,
updatedAt: new Date("2024-02-05"),
};
expect(film.type).toBe(ShowType.film);
expect(documentary.type).toBe(ShowType.documentary);
});
});
describe("createShowDto interface", () => {
it("should accept DTO with required fields only", () => {
const createDto: CreateShowDto = {
status: ShowStatus.wantToWatch,
title: "Stranger Things",
type: ShowType.tvSeries,
};
expect(createDto.rating).toBeUndefined();
expect(createDto.notes).toBeUndefined();
expect(createDto.coverImage).toBeUndefined();
expect(createDto.tags).toBeUndefined();
expect(createDto.links).toBeUndefined();
});
it("should accept DTO with all fields", () => {
const fullCreateDto: CreateShowDto = {
coverImage: "https://example.com/death-note.jpg",
links: [ { title: "Netflix", url: "https://netflix.com/death-note" } ],
notes: "Psychological thriller with great plot",
rating: 4,
status: ShowStatus.watching,
tags: [ "psychological", "thriller", "supernatural" ],
title: "Death Note",
type: ShowType.anime,
};
expect(fullCreateDto.type).toBe(ShowType.anime);
expect(fullCreateDto.rating).toBe(4);
expect(fullCreateDto.tags).toEqual([ "psychological", "thriller", "supernatural" ]);
expect(fullCreateDto.links).toHaveLength(1);
});
});
describe("updateShowDto type", () => {
it("should accept empty update DTO", () => {
const emptyUpdate: UpdateShowDto = {};
expect(emptyUpdate).toEqual({});
});
it("should accept partial updates including dateCompleted", () => {
const partialUpdate: UpdateShowDto = {
dateCompleted: new Date("2024-02-10"),
rating: 5,
status: ShowStatus.completed,
};
expect(partialUpdate.status).toBe(ShowStatus.completed);
expect(partialUpdate.dateCompleted).toEqual(new Date("2024-02-10"));
expect(partialUpdate.rating).toBe(5);
expect(partialUpdate.title).toBeUndefined();
});
it("should accept full update", () => {
const fullUpdate: UpdateShowDto = {
coverImage: "https://example.com/new-show-cover.jpg",
dateCompleted: new Date("2024-02-15"),
links: [ { title: "New Link", url: "https://newlink.com" } ],
notes: "Updated after watching",
rating: 3,
status: ShowStatus.completed,
tags: [ "updated", "tags" ],
title: "Updated Show Title",
type: ShowType.documentary,
};
expect(fullUpdate.title).toBe("Updated Show Title");
expect(fullUpdate.type).toBe(ShowType.documentary);
expect(fullUpdate.dateCompleted).toEqual(new Date("2024-02-15"));
expect(fullUpdate.links).toHaveLength(1);
});
});
});
+440
View File
@@ -0,0 +1,440 @@
/**
* @copyright NHCarrigan
* @license Naomi's Public License
* @author Naomi Carrigan
*/
import { GameStatus, BookStatus, MangaStatus, MusicStatus, MusicType, ShowStatus, ShowType } from "../src";
import { SuggestionEntity, SuggestionStatus } from "../src/lib/suggestion.types";
import type {
Suggestion,
SuggestionUser,
CreateSuggestionDto,
DeclineSuggestionDto,
CreateGameSuggestionDto,
CreateBookSuggestionDto,
CreateMusicSuggestionDto,
CreateArtSuggestionDto,
CreateShowSuggestionDto,
CreateMangaSuggestionDto,
AcceptWithEditsDto,
} from "../src/lib/suggestion.types";
describe("suggestion Types", () => {
describe("suggestionEntity enum", () => {
it("should have the correct values", () => {
expect(SuggestionEntity.game).toBe("GAME");
expect(SuggestionEntity.book).toBe("BOOK");
expect(SuggestionEntity.music).toBe("MUSIC");
expect(SuggestionEntity.art).toBe("ART");
expect(SuggestionEntity.show).toBe("SHOW");
expect(SuggestionEntity.manga).toBe("MANGA");
});
it("should have all expected enum values", () => {
const values = Object.values(SuggestionEntity);
expect(values).toHaveLength(6);
expect(values).toContain("GAME");
expect(values).toContain("BOOK");
expect(values).toContain("MUSIC");
expect(values).toContain("ART");
expect(values).toContain("SHOW");
expect(values).toContain("MANGA");
});
});
describe("suggestionStatus enum", () => {
it("should have the correct values", () => {
expect(SuggestionStatus.unreviewed).toBe("UNREVIEWED");
expect(SuggestionStatus.accepted).toBe("ACCEPTED");
expect(SuggestionStatus.declined).toBe("DECLINED");
});
it("should have all expected enum values", () => {
const values = Object.values(SuggestionStatus);
expect(values).toHaveLength(3);
expect(values).toContain("UNREVIEWED");
expect(values).toContain("ACCEPTED");
expect(values).toContain("DECLINED");
});
});
describe("suggestionUser interface", () => {
it("should accept valid user object", () => {
const user: SuggestionUser = {
avatar: "https://example.com/avatar.png",
id: "user123",
inDiscord: true,
isMod: false,
isStaff: false,
isVip: false,
username: "suggester",
};
expect(user.inDiscord).toBeTruthy();
expect(user.isVip).toBeFalsy();
});
it("should accept user without avatar", () => {
const user: SuggestionUser = {
id: "user456",
inDiscord: true,
isMod: false,
isStaff: true,
isVip: true,
username: "vipuser",
};
expect(user.avatar).toBeUndefined();
expect(user.isVip).toBeTruthy();
expect(user.isStaff).toBeTruthy();
});
});
describe("suggestion interface", () => {
it("should accept game suggestion", () => {
const gameSuggestion: Suggestion = {
createdAt: new Date("2024-01-15"),
entityType: SuggestionEntity.game,
gameData: {
coverImage: "https://example.com/hades.jpg",
notes: "Amazing roguelike",
platform: "Nintendo Switch",
status: GameStatus.backlog,
title: "Hades",
},
id: "sug123",
status: SuggestionStatus.unreviewed,
title: "Hades",
updatedAt: new Date("2024-01-15"),
user: {
id: "user123",
inDiscord: true,
isMod: false,
isStaff: false,
isVip: false,
username: "gamer",
},
userId: "user123",
};
expect(gameSuggestion.entityType).toBe(SuggestionEntity.game);
expect(gameSuggestion.gameData).toBeDefined();
expect(gameSuggestion.bookData).toBeUndefined();
});
it("should accept book suggestion", () => {
const bookSuggestion: Suggestion = {
bookData: {
author: "George Orwell",
isbn: "978-0-452-28423-4",
notes: "Dystopian classic",
status: BookStatus.toRead,
title: "1984",
},
createdAt: new Date("2024-01-20"),
entityType: SuggestionEntity.book,
id: "sug456",
status: SuggestionStatus.accepted,
title: "1984",
updatedAt: new Date("2024-01-21"),
user: {
id: "user456",
inDiscord: false,
isMod: false,
isStaff: false,
isVip: true,
username: "reader",
},
userId: "user456",
};
expect(bookSuggestion.entityType).toBe(SuggestionEntity.book);
expect(bookSuggestion.bookData).toBeDefined();
expect(bookSuggestion.gameData).toBeUndefined();
});
it("should accept declined suggestion with reason", () => {
const declinedSuggestion: Suggestion = {
createdAt: new Date("2024-02-01"),
declineReason: "Already in the library",
entityType: SuggestionEntity.music,
id: "sug789",
musicData: {
artist: "Pink Floyd",
status: MusicStatus.wantToListen,
title: "Dark Side of the Moon",
type: MusicType.album,
},
status: SuggestionStatus.declined,
title: "Dark Side of the Moon",
updatedAt: new Date("2024-02-02"),
user: {
id: "user789",
inDiscord: true,
isMod: false,
isStaff: false,
isVip: false,
username: "suggester",
},
userId: "user789",
};
expect(declinedSuggestion.status).toBe(SuggestionStatus.declined);
expect(declinedSuggestion.declineReason).toBe("Already in the library");
});
it("should accept art suggestion", () => {
const artSuggestion: Suggestion = {
artData: {
artist: "Jane Doe",
description: "A stunning sunset painting",
imageUrl: "https://example.com/sunset.jpg",
title: "Beautiful Sunset",
},
createdAt: new Date("2024-02-10"),
entityType: SuggestionEntity.art,
id: "sug999",
status: SuggestionStatus.unreviewed,
title: "Beautiful Sunset",
updatedAt: new Date("2024-02-10"),
user: {
id: "user999",
inDiscord: true,
isMod: true,
isStaff: false,
isVip: false,
username: "artlover",
},
userId: "user999",
};
expect(artSuggestion.entityType).toBe(SuggestionEntity.art);
expect(artSuggestion.artData).toBeDefined();
});
it("should accept show and manga suggestions", () => {
const showSuggestion: Suggestion = {
createdAt: new Date("2024-02-15"),
entityType: SuggestionEntity.show,
id: "sug111",
showData: {
status: ShowStatus.wantToWatch,
title: "Breaking Bad",
type: ShowType.tvSeries,
},
status: SuggestionStatus.unreviewed,
title: "Breaking Bad",
updatedAt: new Date("2024-02-15"),
user: {
id: "user111",
inDiscord: false,
isMod: false,
isStaff: true,
isVip: false,
username: "tvfan",
},
userId: "user111",
};
const mangaSuggestion: Suggestion = {
createdAt: new Date("2024-02-20"),
entityType: SuggestionEntity.manga,
id: "sug222",
mangaData: {
author: "Eiichiro Oda",
status: MangaStatus.reading,
title: "One Piece",
},
status: SuggestionStatus.accepted,
title: "One Piece",
updatedAt: new Date("2024-02-21"),
user: {
id: "user222",
inDiscord: true,
isMod: true,
isStaff: true,
isVip: true,
username: "mangareader",
},
userId: "user222",
};
expect(showSuggestion.entityType).toBe(SuggestionEntity.show);
expect(mangaSuggestion.entityType).toBe(SuggestionEntity.manga);
});
});
describe("createSuggestionDto types", () => {
it("should accept game suggestion DTO", () => {
const gameDto: CreateGameSuggestionDto = {
coverImage: "https://example.com/hollow.jpg",
entityType: SuggestionEntity.game,
notes: "Great metroidvania",
platform: "PC",
title: "Hollow Knight",
};
expect(gameDto.entityType).toBe(SuggestionEntity.game);
expect(gameDto.platform).toBe("PC");
});
it("should accept book suggestion DTO", () => {
const bookDto: CreateBookSuggestionDto = {
author: "J.R.R. Tolkien",
coverImage: "https://example.com/hobbit.jpg",
entityType: SuggestionEntity.book,
isbn: "978-0-547-92822-7",
notes: "Fantasy classic",
title: "The Hobbit",
};
expect(bookDto.entityType).toBe(SuggestionEntity.book);
expect(bookDto.author).toBe("J.R.R. Tolkien");
});
it("should accept music suggestion DTO", () => {
const musicDto: CreateMusicSuggestionDto = {
artist: "Pink Floyd",
coverArt: "https://example.com/wall.jpg",
entityType: SuggestionEntity.music,
notes: "Rock opera",
title: "The Wall",
type: "ALBUM",
};
expect(musicDto.entityType).toBe(SuggestionEntity.music);
expect(musicDto.type).toBe("ALBUM");
});
it("should accept art suggestion DTO", () => {
const artDto: CreateArtSuggestionDto = {
artist: "Vincent van Gogh",
description: "Famous painting",
entityType: SuggestionEntity.art,
imageUrl: "https://example.com/starry.jpg",
title: "Starry Night",
};
expect(artDto.entityType).toBe(SuggestionEntity.art);
expect(artDto.imageUrl).toBe("https://example.com/starry.jpg");
});
it("should accept show suggestion DTO", () => {
const showDto: CreateShowSuggestionDto = {
coverImage: "https://example.com/office.jpg",
entityType: SuggestionEntity.show,
notes: "Comedy series",
title: "The Office",
type: "TV_SERIES",
};
expect(showDto.entityType).toBe(SuggestionEntity.show);
expect(showDto.type).toBe("TV_SERIES");
});
it("should accept manga suggestion DTO", () => {
const mangaDto: CreateMangaSuggestionDto = {
author: "Tsugumi Ohba",
coverImage: "https://example.com/deathnote.jpg",
entityType: SuggestionEntity.manga,
notes: "Psychological thriller",
title: "Death Note",
};
expect(mangaDto.entityType).toBe(SuggestionEntity.manga);
expect(mangaDto.author).toBe("Tsugumi Ohba");
});
it("should work with union type", () => {
const suggestions: Array<CreateSuggestionDto> = [
{
entityType: SuggestionEntity.game,
title: "Game Title",
},
{
author: "Author Name",
entityType: SuggestionEntity.book,
title: "Book Title",
},
];
expect(suggestions).toHaveLength(2);
expect(suggestions[0].entityType).toBe(SuggestionEntity.game);
expect(suggestions[1].entityType).toBe(SuggestionEntity.book);
});
});
describe("declineSuggestionDto interface", () => {
it("should accept empty decline DTO", () => {
const declineDto: DeclineSuggestionDto = {};
expect(declineDto.reason).toBeUndefined();
});
it("should accept decline DTO with reason", () => {
const declineDto: DeclineSuggestionDto = {
reason: "Already exists in the library",
};
expect(declineDto.reason).toBe("Already exists in the library");
});
});
describe("acceptWithEditsDto interface", () => {
it("should accept empty edits DTO", () => {
const editsDto: AcceptWithEditsDto = {};
expect(editsDto).toEqual({});
});
it("should accept edits for book fields", () => {
const editsDto: AcceptWithEditsDto = {
author: "Corrected Author",
coverImage: "https://example.com/new-cover.jpg",
isbn: "978-0-123-45678-9",
links: [ { label: "Goodreads", url: "https://goodreads.com" } ],
notes: "Updated notes",
tags: [ "fiction", "classic" ],
title: "Corrected Title",
};
expect(editsDto.title).toBe("Corrected Title");
expect(editsDto.author).toBe("Corrected Author");
expect(editsDto.tags).toHaveLength(2);
});
it("should accept edits for music fields", () => {
const editsDto: AcceptWithEditsDto = {
artist: "Artist Name",
coverArt: "https://example.com/album.jpg",
title: "Album Title",
type: "ALBUM",
};
expect(editsDto.artist).toBe("Artist Name");
expect(editsDto.type).toBe("ALBUM");
expect(editsDto.coverArt).toBe("https://example.com/album.jpg");
});
it("should accept edits for game fields", () => {
const editsDto: AcceptWithEditsDto = {
coverImage: "https://example.com/game.jpg",
notes: "Action RPG",
platform: "PlayStation 5",
title: "Game Title",
};
expect(editsDto.platform).toBe("PlayStation 5");
});
it("should accept edits for art fields", () => {
const editsDto: AcceptWithEditsDto = {
artist: "Artist Name",
description: "Beautiful artwork",
imageUrl: "https://example.com/art.jpg",
title: "Art Title",
};
expect(editsDto.description).toBe("Beautiful artwork");
expect(editsDto.imageUrl).toBe("https://example.com/art.jpg");
});
});
});
+3 -7
View File
@@ -1,6 +1,7 @@
{
"extends": "../tsconfig.base.json",
"compilerOptions": {
"outDir": "../dist/out-tsc",
"module": "commonjs",
"forceConsistentCasingInFileNames": true,
"strict": true,
@@ -10,11 +11,6 @@
"noFallthroughCasesInSwitch": true,
"noPropertyAccessFromIndexSignature": true
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
}
]
"include": ["src/**/*.ts", "test/**/*.ts"],
"exclude": ["test/**/*.spec.ts"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "node"]
},
"include": ["test/**/*.spec.ts"]
}