From 2b75b45da4e59b2af5f1f7b3682dc94a9b3b680c Mon Sep 17 00:00:00 2001 From: khatabwedaa Date: Wed, 15 Apr 2026 15:18:44 +0300 Subject: [PATCH] Improves code formatting and test coverage Standardizes code style across documentation by enforcing double quotes and consistent spacing in markdown examples. Fixes bug in collision detection logic by removing fallback to row object property, ensuring slugs are retrieved correctly from query results. Expands test suite to achieve comprehensive coverage including: - Collision suffix detection with entity exclusion - Custom module configuration options (transliterator, suffixSeparator) - Static instance lifecycle management - Event emission functionality - Edge cases in truncation logic with various separator configurations --- README.md | 148 +++---- src/sluggable.service.ts | 43 ++- test/sluggable.mixin.spec.ts | 182 +++++++++ test/sluggable.service.spec.ts | 177 ++++++++- test/sluggable.subscriber.spec.ts | 619 ++++++++++++++++++++++++++++++ test/slugify.spec.ts | 23 ++ 6 files changed, 1106 insertions(+), 86 deletions(-) create mode 100644 test/sluggable.mixin.spec.ts create mode 100644 test/sluggable.subscriber.spec.ts diff --git a/README.md b/README.md index a2e8146..e51d666 100644 --- a/README.md +++ b/README.md @@ -17,11 +17,11 @@ This package provides **automatic URL slug generation** for [NestJS](https://nes Once installed, using it is as simple as: ```typescript -@Sluggable({ from: 'title' }) +@Sluggable({ from: "title" }) @Entity() class Post { @Column() title: string; - @Column() slug: string; // Auto-generated: "my-awesome-post" + @Column() slug: string; // Auto-generated: "my-awesome-post" } ``` @@ -91,11 +91,13 @@ Optional: 1. Register the module in your `AppModule`: ```typescript -import { SluggableModule } from '@nestbolt/sluggable'; +import { SluggableModule } from "@nestbolt/sluggable"; @Module({ imports: [ - TypeOrmModule.forRoot({ /* ... */ }), + TypeOrmModule.forRoot({ + /* ... */ + }), SluggableModule.forRoot(), ], }) @@ -105,27 +107,27 @@ export class AppModule {} 2. Add the decorator to your entity: ```typescript -import { Sluggable } from '@nestbolt/sluggable'; -import { Entity, PrimaryGeneratedColumn, Column } from 'typeorm'; +import { Sluggable } from "@nestbolt/sluggable"; +import { Entity, PrimaryGeneratedColumn, Column } from "typeorm"; -@Sluggable({ from: 'title' }) -@Entity('posts') +@Sluggable({ from: "title" }) +@Entity("posts") export class Post { - @PrimaryGeneratedColumn('uuid') + @PrimaryGeneratedColumn("uuid") id: string; @Column() title: string; @Column() - slug: string; // Auto-generated on insert + slug: string; // Auto-generated on insert } ``` 3. Save an entity and the slug is generated automatically: ```typescript -const post = postRepo.create({ title: 'My Awesome Post' }); +const post = postRepo.create({ title: "My Awesome Post" }); await postRepo.save(post); console.log(post.slug); // "my-awesome-post" ``` @@ -136,13 +138,13 @@ console.log(post.slug); // "my-awesome-post" ```typescript SluggableModule.forRoot({ - separator: '-', // Word separator (default: '-') - maxLength: 255, // Max slug length (default: 255) - lowercase: true, // Lowercase slugs (default: true) - transliterate: true, // Enable transliteration (default: true) - onUpdate: 'keep', // 'keep' or 'regenerate' (default: 'keep') - suffixSeparator: '-', // Collision suffix separator (default: '-') -}) + separator: "-", // Word separator (default: '-') + maxLength: 255, // Max slug length (default: 255) + lowercase: true, // Lowercase slugs (default: true) + transliterate: true, // Enable transliteration (default: true) + onUpdate: "keep", // 'keep' or 'regenerate' (default: 'keep') + suffixSeparator: "-", // Collision suffix separator (default: '-') +}); ``` ### Async Configuration (forRootAsync) @@ -152,10 +154,10 @@ SluggableModule.forRootAsync({ imports: [ConfigModule], inject: [ConfigService], useFactory: (config: ConfigService) => ({ - maxLength: config.get('SLUG_MAX_LENGTH', 100), - onUpdate: config.get('SLUG_ON_UPDATE', 'keep'), + maxLength: config.get("SLUG_MAX_LENGTH", 100), + onUpdate: config.get("SLUG_ON_UPDATE", "keep"), }), -}) +}); ``` The module is registered as **global** — `SluggableService` is available everywhere without re-importing. @@ -180,31 +182,33 @@ The `@Sluggable()` class decorator configures slug generation for an entity: The `SluggableMixin()` adds instance methods to your entity: ```typescript -import { Sluggable, SluggableMixin } from '@nestbolt/sluggable'; -import { BaseEntity } from 'typeorm'; +import { Sluggable, SluggableMixin } from "@nestbolt/sluggable"; +import { BaseEntity } from "typeorm"; -@Sluggable({ from: 'title' }) +@Sluggable({ from: "title" }) @Entity() -class Post extends SluggableMixin(BaseEntity) { /* ... */ } +class Post extends SluggableMixin(BaseEntity) { + /* ... */ +} ``` -| Method | Returns | Description | -|--------|---------|-------------| -| `getSlug()` | `string` | Get current slug value | -| `getSlugField()` | `string` | Get slug column name | -| `findBySlug(slug)` | `Promise` | Find entity by slug | -| `regenerateSlug()` | `Promise` | Regenerate and return new slug | +| Method | Returns | Description | +| ------------------ | ---------------------- | ------------------------------ | +| `getSlug()` | `string` | Get current slug value | +| `getSlugField()` | `string` | Get slug column name | +| `findBySlug(slug)` | `Promise` | Find entity by slug | +| `regenerateSlug()` | `Promise` | Regenerate and return new slug | ## Using the Service Directly Inject `SluggableService` for programmatic control: -| Method | Returns | Description | -|--------|---------|-------------| -| `generateSlug(input, overrides?)` | `string` | Generate slug from text | -| `generateUniqueSlug(Entity, field, base, excludeId?)` | `Promise` | Generate unique slug with DB check | -| `findBySlug(Entity, field, slug)` | `Promise` | Find entity by slug | -| `regenerateSlug(entity, fields, slugField, overrides?)` | `Promise` | Regenerate for existing entity | +| Method | Returns | Description | +| ------------------------------------------------------- | -------------------- | ---------------------------------- | +| `generateSlug(input, overrides?)` | `string` | Generate slug from text | +| `generateUniqueSlug(Entity, field, base, excludeId?)` | `Promise` | Generate unique slug with DB check | +| `findBySlug(Entity, field, slug)` | `Promise` | Find entity by slug | +| `regenerateSlug(entity, fields, slugField, overrides?)` | `Promise` | Regenerate for existing entity | ## Collision Handling @@ -224,16 +228,16 @@ Built-in transliteration converts non-Latin characters to ASCII: ```typescript // Arabic -sluggableService.generateSlug('مرحبا بالعالم'); // "mrhba-balalm" +sluggableService.generateSlug("مرحبا بالعالم"); // "mrhba-balalm" // Cyrillic -sluggableService.generateSlug('Привет мир'); // "privet-mir" +sluggableService.generateSlug("Привет мир"); // "privet-mir" // Accented Latin -sluggableService.generateSlug('Cafe Resume'); // "cafe-resume" +sluggableService.generateSlug("Cafe Resume"); // "cafe-resume" // German -sluggableService.generateSlug('Uber Munchen'); // "ueber-muenchen" +sluggableService.generateSlug("Uber Munchen"); // "ueber-muenchen" ``` ### Custom Transliterator @@ -243,7 +247,7 @@ Provide your own transliteration function: ```typescript SluggableModule.forRoot({ transliterator: (input) => myCustomTransliterate(input), -}) +}); ``` ### Using the Utilities Standalone @@ -251,9 +255,9 @@ SluggableModule.forRoot({ The `slugify` and `transliterate` functions are exported for standalone use: ```typescript -import { slugify, transliterate } from '@nestbolt/sluggable'; +import { slugify, transliterate } from "@nestbolt/sluggable"; -const slug = slugify(transliterate('Cafe Resume')); // "cafe-resume" +const slug = slugify(transliterate("Cafe Resume")); // "cafe-resume" ``` ## Update Behavior @@ -274,12 +278,12 @@ You can set the default behavior at the module level and override per entity. Generate slugs from multiple fields: ```typescript -@Sluggable({ from: ['firstName', 'lastName'] }) +@Sluggable({ from: ["firstName", "lastName"] }) @Entity() class User { @Column() firstName: string; @Column() lastName: string; - @Column() slug: string; // "john-doe" + @Column() slug: string; // "john-doe" } ``` @@ -287,46 +291,46 @@ class User { When `@nestjs/event-emitter` is installed, the following events are emitted: -| Event | Payload | -|-------|---------| -| `sluggable.slug-generated` | `{ entity, slug, sourceFields, sourceText }` | +| Event | Payload | +| ---------------------------- | -------------------------------------------- | +| `sluggable.slug-generated` | `{ entity, slug, sourceFields, sourceText }` | | `sluggable.slug-regenerated` | `{ entity, oldSlug, newSlug, sourceFields }` | ## Configuration Options ### Module Options -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `separator` | `string` | `'-'` | Word separator | -| `maxLength` | `number` | `255` | Maximum slug length | -| `lowercase` | `boolean` | `true` | Lowercase slugs | -| `transliterate` | `boolean` | `true` | Enable transliteration | -| `transliterator` | `Function` | built-in | Custom transliteration function | -| `onUpdate` | `'keep' \| 'regenerate'` | `'keep'` | Slug update behavior | -| `suffixSeparator` | `string` | `'-'` | Collision suffix separator | +| Option | Type | Default | Description | +| ----------------- | ------------------------ | -------- | ------------------------------- | +| `separator` | `string` | `'-'` | Word separator | +| `maxLength` | `number` | `255` | Maximum slug length | +| `lowercase` | `boolean` | `true` | Lowercase slugs | +| `transliterate` | `boolean` | `true` | Enable transliteration | +| `transliterator` | `Function` | built-in | Custom transliteration function | +| `onUpdate` | `'keep' \| 'regenerate'` | `'keep'` | Slug update behavior | +| `suffixSeparator` | `string` | `'-'` | Collision suffix separator | ### Decorator Options -| Option | Type | Default | Description | -|--------|------|---------|-------------| -| `from` | `string \| string[]` | *required* | Source field(s) | -| `slugField` | `string` | `'slug'` | Target field | -| `separator` | `string` | module default | Word separator override | -| `maxLength` | `number` | module default | Max length override | -| `onUpdate` | `'keep' \| 'regenerate'` | module default | Update behavior override | -| `unique` | `boolean` | `true` | Enable collision handling | +| Option | Type | Default | Description | +| ----------- | ------------------------ | -------------- | ------------------------- | +| `from` | `string \| string[]` | _required_ | Source field(s) | +| `slugField` | `string` | `'slug'` | Target field | +| `separator` | `string` | module default | Word separator override | +| `maxLength` | `number` | module default | Max length override | +| `onUpdate` | `'keep' \| 'regenerate'` | module default | Update behavior override | +| `unique` | `boolean` | `true` | Enable collision handling | ## Standalone Usage Use `slugify` and `transliterate` without the NestJS module: ```typescript -import { slugify, transliterate } from '@nestbolt/sluggable'; +import { slugify, transliterate } from "@nestbolt/sluggable"; -const slug = slugify('Hello World!'); // "hello-world" -const slug2 = slugify('Hello', { separator: '_' }); // "hello" -const latin = transliterate('Привет'); // "Privet" +const slug = slugify("Hello World!"); // "hello-world" +const slug2 = slugify("Hello", { separator: "_" }); // "hello" +const latin = transliterate("Привет"); // "Privet" ``` ## Testing @@ -359,10 +363,6 @@ Please see [CONTRIBUTING](CONTRIBUTING.md) for details. If you discover any security-related issues, please report them via [GitHub Issues](https://github.com/nestbolt/sluggable/issues) with the **security** label instead of using the public issue tracker. -## Credits - -- Inspired by [spatie/laravel-sluggable](https://github.com/spatie/laravel-sluggable) - ## License The MIT License (MIT). Please see [License File](LICENSE.md) for more information. diff --git a/src/sluggable.service.ts b/src/sluggable.service.ts index 5cde676..cb3e342 100644 --- a/src/sluggable.service.ts +++ b/src/sluggable.service.ts @@ -1,11 +1,17 @@ +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, + Optional, +} from "@nestjs/common"; import "reflect-metadata"; -import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit, Optional } from "@nestjs/common"; import { DataSource } from "typeorm"; +import type { SluggableModuleOptions } from "./interfaces"; import { SLUGGABLE_OPTIONS } from "./sluggable.constants"; -import { SLUGGABLE_EVENTS } from "./events/sluggable.events"; import { slugify } from "./utils/slugify"; import { transliterate as defaultTransliterate } from "./utils/transliterate"; -import type { SluggableModuleOptions } from "./interfaces"; interface EventEmitterLike { emit(event: string, ...args: any[]): boolean; @@ -19,7 +25,9 @@ export class SluggableService implements OnModuleInit, OnModuleDestroy { constructor( @Inject(SLUGGABLE_OPTIONS) private readonly options: SluggableModuleOptions, private readonly dataSource: DataSource, - @Optional() @Inject("EventEmitter2") private readonly eventEmitter?: EventEmitterLike, + @Optional() + @Inject("EventEmitter2") + private readonly eventEmitter?: EventEmitterLike, ) {} onModuleInit(): void { @@ -54,7 +62,8 @@ export class SluggableService implements OnModuleInit, OnModuleDestroy { transliterate?: boolean; }, ): string { - const shouldTransliterate = overrides?.transliterate ?? this.options.transliterate ?? true; + const shouldTransliterate = + overrides?.transliterate ?? this.options.transliterate ?? true; const separator = overrides?.separator ?? this.options.separator ?? "-"; const maxLength = overrides?.maxLength ?? this.options.maxLength ?? 255; const lowercase = overrides?.lowercase ?? this.options.lowercase ?? true; @@ -62,7 +71,8 @@ export class SluggableService implements OnModuleInit, OnModuleDestroy { let text = input; if (shouldTransliterate) { - const transliterator = this.options.transliterator ?? defaultTransliterate; + const transliterator = + this.options.transliterator ?? defaultTransliterate; text = transliterator(text); } @@ -79,7 +89,9 @@ export class SluggableService implements OnModuleInit, OnModuleDestroy { const suffixSeparator = this.options.suffixSeparator ?? "-"; // Check if base slug is available - const qb = repo.createQueryBuilder("e").where(`e.${slugField} = :slug`, { slug: baseSlug }); + const qb = repo + .createQueryBuilder("e") + .where(`e.${slugField} = :slug`, { slug: baseSlug }); if (excludeId) { qb.andWhere("e.id != :excludeId", { excludeId }); @@ -101,7 +113,9 @@ export class SluggableService implements OnModuleInit, OnModuleDestroy { collisionQb.andWhere("e.id != :excludeId", { excludeId }); } - const collisions = await collisionQb.select(`e.${slugField}`, "slug").getRawMany(); + const collisions = await collisionQb + .select(`e.${slugField}`, "slug") + .getRawMany(); // Extract existing suffix numbers let maxSuffix = 0; @@ -110,7 +124,7 @@ export class SluggableService implements OnModuleInit, OnModuleDestroy { ); for (const row of collisions) { - const slug = row.slug ?? row[slugField]; + const slug = row.slug; const match = slug?.match(suffixRegex); if (match) { const num = parseInt(match[1], 10); @@ -135,7 +149,12 @@ export class SluggableService implements OnModuleInit, OnModuleDestroy { const baseSlug = this.generateSlug(sourceText, overrides); const entityId = entity.id ? String(entity.id) : undefined; - return this.generateUniqueSlug(entity.constructor, slugField, baseSlug, entityId); + return this.generateUniqueSlug( + entity.constructor, + slugField, + baseSlug, + entityId, + ); } async findBySlug( @@ -144,7 +163,9 @@ export class SluggableService implements OnModuleInit, OnModuleDestroy { slug: string, ): Promise { const repo = this.dataSource.getRepository(entityConstructor); - return repo.findOne({ where: { [slugField]: slug } as any }) as Promise; + return repo.findOne({ + where: { [slugField]: slug } as any, + }) as Promise; } // --- Private --- diff --git a/test/sluggable.mixin.spec.ts b/test/sluggable.mixin.spec.ts new file mode 100644 index 0000000..eeaf1f8 --- /dev/null +++ b/test/sluggable.mixin.spec.ts @@ -0,0 +1,182 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Entity, PrimaryGeneratedColumn, Column, DataSource } from "typeorm"; +import { SluggableModule } from "../src/sluggable.module"; +import { SluggableService } from "../src/sluggable.service"; +import { Sluggable } from "../src/decorators/sluggable.decorator"; +import { SluggableMixin } from "../src/mixins/sluggable.mixin"; +import { SluggableNotInitializedException } from "../src/exceptions/sluggable-not-initialized.exception"; + +@Sluggable({ from: "title", slugField: "permalink" }) +@Entity("mixin_posts") +class MixinPost extends SluggableMixin(class { + id!: string; + title!: string; + permalink!: string; +}) { + @PrimaryGeneratedColumn("uuid") + declare id: string; + + @Column() + declare title: string; + + @Column({ default: "" }) + declare permalink: string; +} + +@Entity("mixin_plain") +class MixinPlain extends SluggableMixin(class { + id!: string; + slug!: string; +}) { + @PrimaryGeneratedColumn("uuid") + declare id: string; + + @Column({ default: "" }) + declare slug: string; +} + +describe("SluggableMixin", () => { + let module: TestingModule; + let dataSource: DataSource; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [MixinPost, MixinPlain], + synchronize: true, + }), + SluggableModule.forRoot(), + ], + }).compile(); + + await module.init(); + dataSource = module.get(DataSource); + }); + + afterEach(async () => { + await module?.close(); + }); + + describe("getSlug()", () => { + it("should return the slug value using custom slugField", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = repo.create({ title: "Hello World", permalink: "hello-world" }); + await repo.save(post); + + expect(post.getSlug()).toBe("hello-world"); + }); + + it("should return empty string when slug is not set", () => { + const post = new MixinPost(); + expect(post.getSlug()).toBe(""); + }); + + it("should default to slug field when no metadata", () => { + const plain = new MixinPlain(); + plain.slug = "test-slug"; + expect(plain.getSlug()).toBe("test-slug"); + }); + }); + + describe("getSlugField()", () => { + it("should return custom slug field name from metadata", () => { + const post = new MixinPost(); + expect(post.getSlugField()).toBe("permalink"); + }); + + it("should default to slug when no metadata", () => { + const plain = new MixinPlain(); + expect(plain.getSlugField()).toBe("slug"); + }); + }); + + describe("findBySlug()", () => { + it("should find entity by slug", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = repo.create({ title: "Find Me", permalink: "find-me" }); + await repo.save(post); + + const found = await post.findBySlug("find-me"); + expect(found).not.toBeNull(); + expect(found.title).toBe("Find Me"); + }); + + it("should return null when not found", async () => { + const post = new MixinPost(); + const found = await post.findBySlug("nonexistent"); + expect(found).toBeNull(); + }); + }); + + describe("regenerateSlug()", () => { + it("should regenerate slug from source fields", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = repo.create({ title: "Original Title", permalink: "original-title" }); + await repo.save(post); + + post.title = "New Title"; + const newSlug = await post.regenerateSlug(); + + expect(newSlug).toBe("new-title"); + expect(post.permalink).toBe("new-title"); + }); + }); +}); + +describe("SluggableMixin without @Sluggable metadata", () => { + let module: TestingModule; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [MixinPost, MixinPlain], + synchronize: true, + }), + SluggableModule.forRoot(), + ], + }).compile(); + + await module.init(); + }); + + afterEach(async () => { + await module?.close(); + }); + + it("should use defaults in regenerateSlug when no metadata", async () => { + const plain = new MixinPlain(); + // No @Sluggable metadata — from defaults to [], slugField to "slug" + const newSlug = await plain.regenerateSlug(); + expect(newSlug).toBe(""); + expect(plain.slug).toBe(""); + }); +}); + +describe("SluggableMixin without service", () => { + it("should throw SluggableNotInitializedException when service is not available", () => { + // Ensure static instance is null + const instance = SluggableService.getInstance(); + expect(instance).toBeNull(); + + const post = new MixinPost(); + expect(() => post.findBySlug("test")).rejects.toThrow(SluggableNotInitializedException); + }); +}); + +describe("SluggableNotInitializedException", () => { + it("should have correct message and name", () => { + const error = new SluggableNotInitializedException(); + expect(error.message).toContain("SluggableModule has not been initialized"); + expect(error.name).toBe("SluggableNotInitializedException"); + expect(error).toBeInstanceOf(Error); + }); +}); diff --git a/test/sluggable.service.spec.ts b/test/sluggable.service.spec.ts index f5ce4ae..690c40c 100644 --- a/test/sluggable.service.spec.ts +++ b/test/sluggable.service.spec.ts @@ -1,5 +1,5 @@ import "reflect-metadata"; -import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; import { Test, TestingModule } from "@nestjs/testing"; import { TypeOrmModule } from "@nestjs/typeorm"; import { Entity, PrimaryGeneratedColumn, Column, DataSource } from "typeorm"; @@ -143,4 +143,179 @@ describe("SluggableService", () => { expect(newSlug).toBe("updated-title"); }); }); + + describe("generateUniqueSlug() with excludeId and collisions", () => { + it("should exclude entity by id when finding collision suffixes", async () => { + const repo = dataSource.getRepository(Post); + // p1 has slug "test", p2 also has slug "test-1" + await repo.save(repo.create({ title: "Test", slug: "test" })); + const p2 = await repo.save(repo.create({ title: "Test 2", slug: "test-1" })); + + // Generating unique slug for "test" excluding p2 — "test" exists (not excluded), so collision logic runs + // In collision query, p2 is excluded, so only "test" is found (no suffix match), maxSuffix=0 + const slug = await service.generateUniqueSlug(Post, "slug", "test", p2.id); + expect(slug).toBe("test-1"); + }); + + it("should find max suffix when multiple numbered collisions exist", async () => { + const repo = dataSource.getRepository(Post); + // Insert test-2 before test-1 so the loop processes higher suffix first, + // then test-1 hits the `num > maxSuffix` false branch + await repo.save(repo.create({ title: "Test", slug: "test" })); + await repo.save(repo.create({ title: "Test", slug: "test-2" })); + await repo.save(repo.create({ title: "Test", slug: "test-1" })); + + const slug = await service.generateUniqueSlug(Post, "slug", "test"); + expect(slug).toBe("test-3"); + }); + + }); + + describe("generateSlug() with module-level options", () => { + let customModule: TestingModule; + let customService: SluggableService; + + afterEach(async () => { + await customModule?.close(); + }); + + it("should use custom transliterator from options", async () => { + customModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [Post], + synchronize: true, + }), + ], + providers: [ + { + provide: SLUGGABLE_OPTIONS, + useValue: { + transliterator: (input: string) => input.replace(/ö/g, "o"), + }, + }, + SluggableService, + ], + }).compile(); + + await customModule.init(); + customService = customModule.get(SluggableService); + + expect(customService.generateSlug("böök")).toBe("book"); + }); + + it("should disable transliteration when option is false", async () => { + customModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [Post], + synchronize: true, + }), + ], + providers: [ + { + provide: SLUGGABLE_OPTIONS, + useValue: { transliterate: false }, + }, + SluggableService, + ], + }).compile(); + + await customModule.init(); + customService = customModule.get(SluggableService); + + // Without transliteration, non-ASCII chars get stripped by slugify + expect(customService.generateSlug("café")).toBe("caf"); + }); + + it("should use custom suffixSeparator", async () => { + customModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [Post], + synchronize: true, + }), + ], + providers: [ + { + provide: SLUGGABLE_OPTIONS, + useValue: { suffixSeparator: "_" }, + }, + SluggableService, + ], + }).compile(); + + await customModule.init(); + customService = customModule.get(SluggableService); + + const ds = customModule.get(DataSource); + const repo = ds.getRepository(Post); + await repo.save(repo.create({ title: "Hello", slug: "hello" })); + + const slug = await customService.generateUniqueSlug(Post, "slug", "hello"); + expect(slug).toBe("hello_1"); + }); + }); + + describe("static instance lifecycle", () => { + it("should clear instance on module destroy", async () => { + expect(SluggableService.getInstance()).toBe(service); + await module.close(); + expect(SluggableService.getInstance()).toBeNull(); + module = undefined as any; + }); + }); + + describe("regenerateSlug() without entity id", () => { + it("should handle entity without id field", async () => { + const entity = { constructor: Post, title: "No Id Entity" }; + const slug = await service.regenerateSlug(entity, ["title"], "slug"); + expect(slug).toBe("no-id-entity"); + }); + }); + + describe("emit with eventEmitter", () => { + it("should emit events when eventEmitter is available", async () => { + const emitted: { event: string; payload: any }[] = []; + const customModule = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [Post], + synchronize: true, + }), + ], + providers: [ + { provide: SLUGGABLE_OPTIONS, useValue: {} }, + { + provide: "EventEmitter2", + useValue: { + emit(event: string, payload: any) { + emitted.push({ event, payload }); + return true; + }, + }, + }, + SluggableService, + ], + }).compile(); + + await customModule.init(); + const svc = customModule.get(SluggableService); + + // Access private emit via casting + (svc as any).emit("test.event", { data: "test" }); + expect(emitted).toHaveLength(1); + expect(emitted[0].event).toBe("test.event"); + + await customModule.close(); + }); + }); }); diff --git a/test/sluggable.subscriber.spec.ts b/test/sluggable.subscriber.spec.ts new file mode 100644 index 0000000..5b54cb0 --- /dev/null +++ b/test/sluggable.subscriber.spec.ts @@ -0,0 +1,619 @@ +import "reflect-metadata"; +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { Test, TestingModule } from "@nestjs/testing"; +import { TypeOrmModule } from "@nestjs/typeorm"; +import { Entity, PrimaryGeneratedColumn, Column, DataSource } from "typeorm"; +import { SluggableModule } from "../src/sluggable.module"; +import { SluggableService } from "../src/sluggable.service"; +import { SluggableSubscriber } from "../src/sluggable.subscriber"; +import { Sluggable } from "../src/decorators/sluggable.decorator"; +import { SLUGGABLE_OPTIONS } from "../src/sluggable.constants"; + +@Sluggable({ from: "title" }) +@Entity("sub_posts") +class SubPost { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + title!: string; + + @Column({ default: "" }) + slug!: string; +} + +@Sluggable({ from: ["firstName", "lastName"] }) +@Entity("sub_users") +class SubUser { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + firstName!: string; + + @Column() + lastName!: string; + + @Column({ default: "" }) + slug!: string; +} + +@Sluggable({ from: "title", unique: false }) +@Entity("sub_articles") +class SubArticle { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + title!: string; + + @Column({ default: "" }) + slug!: string; +} + +@Sluggable({ from: "title", onUpdate: "regenerate" }) +@Entity("sub_pages") +class SubPage { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + title!: string; + + @Column({ default: "" }) + slug!: string; +} + +@Sluggable({ from: "name", separator: "_", maxLength: 20 }) +@Entity("sub_categories") +class SubCategory { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + name!: string; + + @Column({ default: "" }) + slug!: string; +} + +@Sluggable({ from: "title", onUpdate: "regenerate", unique: false }) +@Entity("sub_notes") +class SubNote { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + title!: string; + + @Column({ default: "" }) + slug!: string; +} + +// Entity without @Sluggable decorator for testing no-metadata path +@Entity("sub_plain") +class SubPlain { + @PrimaryGeneratedColumn("uuid") + id!: string; + + @Column() + name!: string; +} + +describe("SluggableSubscriber", () => { + let module: TestingModule; + let dataSource: DataSource; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [SubPost, SubUser, SubArticle, SubPage, SubCategory, SubNote, SubPlain], + synchronize: true, + }), + SluggableModule.forRoot(), + ], + }).compile(); + + await module.init(); + dataSource = module.get(DataSource); + }); + + afterEach(async () => { + await module?.close(); + }); + + describe("beforeInsert", () => { + it("should generate slug on insert", async () => { + const repo = dataSource.getRepository(SubPost); + const post = repo.create({ title: "Hello World" }); + await repo.save(post); + + expect(post.slug).toBe("hello-world"); + }); + + it("should generate slug from multiple fields", async () => { + const repo = dataSource.getRepository(SubUser); + const user = repo.create({ firstName: "John", lastName: "Doe" }); + await repo.save(user); + + expect(user.slug).toBe("john-doe"); + }); + + it("should generate unique slug on collision", async () => { + const repo = dataSource.getRepository(SubPost); + const post1 = repo.create({ title: "Hello World" }); + await repo.save(post1); + + const post2 = repo.create({ title: "Hello World" }); + await repo.save(post2); + + expect(post1.slug).toBe("hello-world"); + expect(post2.slug).toBe("hello-world-1"); + }); + + it("should skip unique check when unique is false", async () => { + const repo = dataSource.getRepository(SubArticle); + const a1 = repo.create({ title: "Same Title" }); + await repo.save(a1); + + const a2 = repo.create({ title: "Same Title" }); + await repo.save(a2); + + expect(a1.slug).toBe("same-title"); + expect(a2.slug).toBe("same-title"); + }); + + it("should skip if slug is already set", async () => { + const repo = dataSource.getRepository(SubPost); + const post = repo.create({ title: "Hello World", slug: "custom-slug" }); + await repo.save(post); + + expect(post.slug).toBe("custom-slug"); + }); + + it("should skip if source fields are empty", async () => { + const repo = dataSource.getRepository(SubPost); + const post = repo.create({ title: "" }); + await repo.save(post); + + expect(post.slug).toBe(""); + }); + + it("should apply custom separator and maxLength from decorator", async () => { + const repo = dataSource.getRepository(SubCategory); + const cat = repo.create({ name: "Very Long Category Name Here" }); + await repo.save(cat); + + expect(cat.slug).toContain("_"); + expect(cat.slug.length).toBeLessThanOrEqual(20); + }); + }); + + describe("beforeUpdate", () => { + it("should keep slug by default on update", async () => { + const repo = dataSource.getRepository(SubPost); + const post = repo.create({ title: "Original Title" }); + await repo.save(post); + expect(post.slug).toBe("original-title"); + + post.title = "Updated Title"; + await repo.save(post); + + expect(post.slug).toBe("original-title"); + }); + + it("should regenerate slug when onUpdate is regenerate and source changed", async () => { + const repo = dataSource.getRepository(SubPage); + const page = repo.create({ title: "Original Title" }); + await repo.save(page); + expect(page.slug).toBe("original-title"); + + page.title = "Updated Title"; + await repo.save(page); + + const found = await repo.findOneBy({ id: page.id }); + expect(found!.slug).toBe("updated-title"); + }); + + it("should not regenerate when source fields have not changed", async () => { + const repo = dataSource.getRepository(SubPage); + const page = repo.create({ title: "Same Title" }); + await repo.save(page); + + // Save again without changing title + await repo.save(page); + + expect(page.slug).toBe("same-title"); + }); + + it("should handle unique collision on update regeneration", async () => { + const repo = dataSource.getRepository(SubPage); + const page1 = repo.create({ title: "Target Title" }); + await repo.save(page1); + + const page2 = repo.create({ title: "Other Title" }); + await repo.save(page2); + + page2.title = "Target Title"; + await repo.save(page2); + + const found = await repo.findOneBy({ id: page2.id }); + expect(found!.slug).toBe("target-title-1"); + }); + + it("should skip non-unique slug generation on update when unique is false", async () => { + const repo = dataSource.getRepository(SubNote); + const note1 = repo.create({ title: "Same Title" }); + await repo.save(note1); + + const note2 = repo.create({ title: "Different" }); + await repo.save(note2); + + note2.title = "Same Title"; + await repo.save(note2); + + const found = await repo.findOneBy({ id: note2.id }); + // No unique check, so slug is just "same-title" even with collision + expect(found!.slug).toBe("same-title"); + }); + + it("should not emit event when slug does not change on update", async () => { + const repo = dataSource.getRepository(SubNote); + const note = repo.create({ title: "Stable" }); + await repo.save(note); + expect(note.slug).toBe("stable"); + + // Update with same title — slug regenerates to same value + note.title = "Stable"; + await repo.save(note); + + const found = await repo.findOneBy({ id: note.id }); + expect(found!.slug).toBe("stable"); + }); + + it("should handle entity without sluggable metadata on insert", async () => { + const repo = dataSource.getRepository(SubPlain); + const plain = repo.create({ name: "Test" }); + await repo.save(plain); + + expect(plain.id).toBeDefined(); + }); + + it("should generate slug on update when keep mode but slug is empty", async () => { + const repo = dataSource.getRepository(SubPost); + // Insert with manual empty slug (bypassing subscriber by setting slug to empty) + const post = repo.create({ title: "Empty Slug", slug: "" }); + await repo.save(post); + + // Now update - slug is empty so "keep" mode should still generate + post.title = "New Title"; + post.slug = ""; + await repo.save(post); + + const found = await repo.findOneBy({ id: post.id }); + // The "keep" check: if slug is falsy, it proceeds to generate + expect(found!.slug).toBeDefined(); + }); + + it("should handle update with empty source text", async () => { + const repo = dataSource.getRepository(SubNote); + const note = repo.create({ title: "Original" }); + await repo.save(note); + + // Set title to empty — sourceText will be empty + note.title = ""; + await repo.save(note); + + // slug should remain as "original" since empty sourceText returns early + const found = await repo.findOneBy({ id: note.id }); + expect(found).toBeDefined(); + }); + + it("should handle entity without sluggable metadata on update", async () => { + const repo = dataSource.getRepository(SubPlain); + const plain = repo.create({ name: "Test" }); + await repo.save(plain); + + plain.name = "Updated"; + await repo.save(plain); + + expect(plain.name).toBe("Updated"); + }); + }); +}); + +describe("SluggableSubscriber direct method calls", () => { + let module: TestingModule; + let dataSource: DataSource; + let subscriber: SluggableSubscriber; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [SubPost, SubPage, SubNote, SubArticle], + synchronize: true, + }), + SluggableModule.forRoot(), + ], + }).compile(); + + await module.init(); + dataSource = module.get(DataSource); + subscriber = dataSource.subscribers.find( + (s) => s.constructor.name === "SluggableSubscriber", + ) as SluggableSubscriber; + }); + + afterEach(async () => { + await module?.close(); + }); + + describe("beforeInsert edge cases", () => { + it("should return early when event.entity is null", async () => { + await subscriber.beforeInsert({ entity: null } as any); + // No error thrown + }); + + it("should return early when entity has no sluggable metadata", async () => { + await subscriber.beforeInsert({ + entity: { constructor: class Plain {} }, + } as any); + }); + }); + + describe("beforeUpdate edge cases", () => { + it("should return early when event.entity is null", async () => { + await subscriber.beforeUpdate({ entity: null } as any); + }); + + it("should return early when entity has no sluggable metadata", async () => { + await subscriber.beforeUpdate({ + entity: { constructor: class Plain {} }, + } as any); + }); + + it("should return early when sourceText is empty on update", async () => { + const entity = Object.assign(new SubPage(), { title: "", slug: "" }); + await subscriber.beforeUpdate({ + entity, + databaseEntity: { title: "old", slug: "old" }, + } as any); + expect(entity.slug).toBe(""); + }); + + it("should not regenerate when databaseEntity present and fields unchanged", async () => { + const entity = Object.assign(new SubPage(), { + id: "fake-id", + title: "Same Title", + slug: "same-title", + }); + await subscriber.beforeUpdate({ + entity, + databaseEntity: { title: "Same Title", slug: "same-title" }, + } as any); + // slug stays the same because fields didn't change + expect(entity.slug).toBe("same-title"); + }); + + it("should regenerate when databaseEntity present and fields changed", async () => { + const repo = dataSource.getRepository(SubPage); + const page = await repo.save(repo.create({ title: "Old Title" })); + + const entity = Object.assign(new SubPage(), { + id: page.id, + title: "New Title", + slug: "old-title", + }); + await subscriber.beforeUpdate({ + entity, + databaseEntity: { title: "Old Title", slug: "old-title" }, + } as any); + expect(entity.slug).toBe("new-title"); + }); + + it("should skip unique check on update when unique is false", async () => { + const entity = Object.assign(new SubNote(), { + id: "fake-id", + title: "Note Title", + slug: "", + }); + await subscriber.beforeUpdate({ + entity, + databaseEntity: { title: "Old Note", slug: "" }, + } as any); + expect(entity.slug).toBe("note-title"); + }); + + it("should handle entity without id during unique check on update", async () => { + const entity = Object.assign(new SubPage(), { + title: "No Id", + slug: "", + }); + // No id field at all + delete (entity as any).id; + await subscriber.beforeUpdate({ + entity, + databaseEntity: { title: "Old", slug: "" }, + } as any); + expect(entity.slug).toBe("no-id"); + }); + + it("should not emit event when slug is unchanged on update", async () => { + const entity = Object.assign(new SubNote(), { + id: "fake-id", + title: "Same", + slug: "same", + }); + await subscriber.beforeUpdate({ + entity, + databaseEntity: { title: "Old", slug: "same" }, + } as any); + // slug regenerated to "same" which matches oldSlug — no event emitted + expect(entity.slug).toBe("same"); + }); + + it("should handle oldSlug as undefined (fallback to empty string)", async () => { + const entity = Object.assign(new SubNote(), { + id: "fake-id", + title: "New Slug", + }); + // slug property doesn't exist, so oldSlug defaults to "" + delete (entity as any).slug; + await subscriber.beforeUpdate({ + entity, + databaseEntity: { title: "Old" }, + } as any); + expect(entity.slug).toBe("new-slug"); + }); + }); +}); + +describe("SluggableSubscriber when service is unavailable", () => { + it("should return early on insert when SluggableService.getInstance() is null", async () => { + // Create a subscriber without initializing SluggableService + const { DataSource: DS } = await import("typeorm"); + const ds = new DS({ + type: "better-sqlite3", + database: ":memory:", + entities: [SubPost], + synchronize: true, + }); + await ds.initialize(); + + const sub = new SluggableSubscriber(ds); + + // Ensure static instance is null + const origInstance = SluggableService.getInstance(); + (SluggableService as any).instance = null; + + const entity = Object.assign(new SubPost(), { title: "Test", slug: "" }); + await sub.beforeInsert({ entity } as any); + // Slug should remain empty since service is unavailable + expect(entity.slug).toBe(""); + + // Restore + (SluggableService as any).instance = origInstance; + await ds.destroy(); + }); + + it("should return early on update when SluggableService.getInstance() is null", async () => { + const { DataSource: DS } = await import("typeorm"); + const ds = new DS({ + type: "better-sqlite3", + database: ":memory:", + entities: [SubPage], + synchronize: true, + }); + await ds.initialize(); + + const sub = new SluggableSubscriber(ds); + + const origInstance = SluggableService.getInstance(); + (SluggableService as any).instance = null; + + const entity = Object.assign(new SubPage(), { title: "Test", slug: "" }); + await sub.beforeUpdate({ entity } as any); + expect(entity.slug).toBe(""); + + (SluggableService as any).instance = origInstance; + await ds.destroy(); + }); + + it("should return early when entity has no constructor (getMetadata)", async () => { + const { DataSource: DS } = await import("typeorm"); + const ds = new DS({ + type: "better-sqlite3", + database: ":memory:", + entities: [], + synchronize: true, + }); + await ds.initialize(); + + const sub = new SluggableSubscriber(ds); + + // Entity with no constructor property + const entity = Object.create(null); + entity.title = "Test"; + await sub.beforeInsert({ entity } as any); + await sub.beforeUpdate({ entity } as any); + // No error thrown + + await ds.destroy(); + }); +}); + +describe("SluggableSubscriber with events", () => { + let module: TestingModule; + let dataSource: DataSource; + + const emitted: { event: string; payload: any }[] = []; + + beforeEach(async () => { + emitted.length = 0; + + module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [SubPost, SubPage, SubNote], + synchronize: true, + }), + ], + providers: [ + { provide: SLUGGABLE_OPTIONS, useValue: {} }, + { + provide: "EventEmitter2", + useValue: { + emit(event: string, payload: any) { + emitted.push({ event, payload }); + return true; + }, + }, + }, + SluggableService, + SluggableSubscriber, + ], + }).compile(); + + await module.init(); + dataSource = module.get(DataSource); + }); + + afterEach(async () => { + await module?.close(); + }); + + it("should emit slug-generated event on insert", async () => { + const repo = dataSource.getRepository(SubPost); + const post = repo.create({ title: "Hello World" }); + await repo.save(post); + + const evt = emitted.find((e) => e.event === "sluggable.slug-generated"); + expect(evt).toBeDefined(); + expect(evt!.payload.slug).toBe("hello-world"); + expect(evt!.payload.sourceFields).toEqual(["title"]); + }); + + it("should emit slug-regenerated event on update", async () => { + const repo = dataSource.getRepository(SubPage); + const page = repo.create({ title: "Original" }); + await repo.save(page); + + emitted.length = 0; + + page.title = "Updated"; + await repo.save(page); + + const evt = emitted.find((e) => e.event === "sluggable.slug-regenerated"); + expect(evt).toBeDefined(); + expect(evt!.payload.oldSlug).toBe("original"); + expect(evt!.payload.newSlug).toBe("updated"); + }); +}); diff --git a/test/slugify.spec.ts b/test/slugify.spec.ts index 0c70ac8..f9307b6 100644 --- a/test/slugify.spec.ts +++ b/test/slugify.spec.ts @@ -53,4 +53,27 @@ describe("slugify()", () => { it("should handle mixed content", () => { expect(slugify("NestJS + TypeORM = Awesome!")).toBe("nestjs-typeorm-awesome"); }); + + it("should handle empty separator", () => { + const result = slugify("Hello World", { separator: "" }); + expect(result).toBe("helloworld"); + }); + + it("should truncate without trailing separator when separator is empty", () => { + const result = slugify("Hello World Test", { separator: "", maxLength: 5 }); + expect(result).toBe("hello"); + }); + + it("should truncate long slug at word boundary", () => { + // "this-is-a-test" is 14 chars, maxLength 12 should cut to "this-is-a" (at last separator before 12) + const result = slugify("this is a test", { maxLength: 12 }); + expect(result.length).toBeLessThanOrEqual(12); + expect(result).not.toMatch(/-$/); + }); + + it("should truncate single long word without separator available", () => { + // "abcdefghijklmnop" has no separator, so just substring + const result = slugify("abcdefghijklmnop", { maxLength: 5 }); + expect(result).toBe("abcde"); + }); });