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"); + }); });