diff --git a/CHANGELOG.md b/CHANGELOG.md index 782f8a8..a6929c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,10 +6,11 @@ All notable changes to `@nestbolt/taggable` will be documented in this file. ### Features -- **Tag entity** — Dedicated `tags` table with name, slug, type/group, and optional metadata -- **Polymorphic tagging** — `taggables` pivot table enables tagging any entity type +- **Tag entity** — Dedicated `tags` table with name, slug, type/group, and optional JSON metadata +- **Polymorphic tagging** — `taggables` pivot table enables tagging any entity type via `taggableType` + `taggableId` - **@Taggable() decorator** — Class decorator to mark entities as taggable with optional type override -- **Tag service** — Full CRUD for tags plus attach/detach/sync operations on entities -- **Entity mixin** — `TaggableMixin()` adds `attachTag()`, `detachTag()`, `syncTags()`, `getTags()`, `hasTag()` to entities +- **Tag service** — Full CRUD for tags (`createTag`, `findOrCreateTag`, `findTagById`, `findTagBySlug`, `findTagsByType`, `getAllTags`, `deleteTag`) plus attach/detach/sync operations on entities +- **Entity mixin** — `TaggableMixin()` adds `attachTag()`, `detachTag()`, `syncTags()`, `getTags()`, `hasTag()`, `getTagCount()` to entities - **Events** — Emits `tag.created`, `tag.deleted`, `tag.attached`, `tag.detached` via optional `@nestjs/event-emitter` -- **Module configuration** — `forRoot()` and `forRootAsync()` with global options +- **Unique constraints** — Composite unique indexes on `[slug, type]` for tags and `[tagId, taggableType, taggableId]` for pivot records +- **Module configuration** — `forRoot()` and `forRootAsync()` with global registration diff --git a/README.md b/README.md index d76e718..183a148 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,6 @@ await taggableService.attachTag(Post, postId, tag.id); - [Entity Mixin](#entity-mixin) - [Events](#events) - [Using the Service Directly](#using-the-service-directly) -- [Configuration Options](#configuration-options) - [Tag Entity](#tag-entity) - [Testing](#testing) - [Changelog](#changelog) @@ -98,7 +97,9 @@ import { TaggableModule } from "@nestbolt/taggable"; @Module({ imports: [ - TypeOrmModule.forRoot({ /* ... */ }), + TypeOrmModule.forRoot({ + /* ... */ + }), TaggableModule.forRoot(), ], }) @@ -163,8 +164,8 @@ The `@Taggable()` class decorator marks an entity for polymorphic tagging: @Taggable({ type: "BlogPost" }) // custom type override ``` -| Option | Type | Default | Description | -| ------ | -------- | ---------- | ----------------------------------------------- | +| Option | Type | Default | Description | +| ------ | -------- | ---------- | ------------------------------------------------ | | `type` | `string` | Class name | Override the entity type name in taggables table | ## Tag CRUD @@ -174,7 +175,9 @@ The `@Taggable()` class decorator marks an entity for polymorphic tagging: const tag = await taggableService.createTag("JavaScript", { type: "language" }); // Create with metadata -const tag = await taggableService.createTag("VIP", { metadata: { color: "gold" } }); +const tag = await taggableService.createTag("VIP", { + metadata: { color: "gold" }, +}); // Find or create (by slug) const tag = await taggableService.findOrCreateTag("TypeScript"); @@ -229,27 +232,27 @@ const hasTag = await post.hasTag(tagId); const count = await post.getTagCount(); ``` -| Method | Returns | Description | -| ------------------- | -------------------- | ------------------------- | -| `attachTag(tagId)` | `Promise` | Attach a tag | -| `detachTag(tagId)` | `Promise` | Detach a tag | -| `syncTags(tagIds)` | `Promise` | Sync tags | -| `getTags()` | `Promise` | Get all tags | -| `hasTag(tagId)` | `Promise` | Check if entity has tag | -| `getTagCount()` | `Promise` | Count tags | -| `getTaggableType()` | `string` | Get the entity type name | -| `getTaggableId()` | `string` | Get the entity ID | +| Method | Returns | Description | +| ------------------- | ---------------------- | ------------------------ | +| `attachTag(tagId)` | `Promise` | Attach a tag | +| `detachTag(tagId)` | `Promise` | Detach a tag | +| `syncTags(tagIds)` | `Promise` | Sync tags | +| `getTags()` | `Promise` | Get all tags | +| `hasTag(tagId)` | `Promise` | Check if entity has tag | +| `getTagCount()` | `Promise` | Count tags | +| `getTaggableType()` | `string` | Get the entity type name | +| `getTaggableId()` | `string` | Get the entity ID | ## Events When `@nestjs/event-emitter` is installed, the package emits: -| Event | Payload | When | -| -------------- | --------------------------------------------- | ------------------------- | -| `tag.created` | `{ tag }` | After a tag is created | -| `tag.deleted` | `{ tagId, tagName }` | After a tag is deleted | -| `tag.attached` | `{ tag, taggableType, taggableId }` | After a tag is attached | -| `tag.detached` | `{ tagId, taggableType, taggableId }` | After a tag is detached | +| Event | Payload | When | +| -------------- | ------------------------------------- | ----------------------- | +| `tag.created` | `{ tag }` | After a tag is created | +| `tag.deleted` | `{ tagId, tagName }` | After a tag is deleted | +| `tag.attached` | `{ tag, taggableType, taggableId }` | After a tag is attached | +| `tag.detached` | `{ tagId, taggableType, taggableId }` | After a tag is detached | ```typescript import { TAGGABLE_EVENTS, TagAttachedEvent } from "@nestbolt/taggable"; @@ -274,50 +277,50 @@ export class PostService { async categorizePost(postId: string, categoryNames: string[]) { const tags = await Promise.all( - categoryNames.map((name) => this.taggableService.findOrCreateTag(name, { type: "category" })), + categoryNames.map((name) => + this.taggableService.findOrCreateTag(name, { type: "category" }), + ), + ); + await this.taggableService.syncTags( + Post, + postId, + tags.map((t) => t.id), ); - await this.taggableService.syncTags(Post, postId, tags.map((t) => t.id)); } } ``` -| Method | Returns | Description | -| --------------------------------------- | -------------------------- | ------------------------- | -| `createTag(name, opts?)` | `Promise` | Create a new tag | -| `findOrCreateTag(name, opts?)` | `Promise` | Find by slug or create | -| `findTagById(id)` | `Promise` | Find tag by ID | -| `findTagBySlug(slug, type?)` | `Promise` | Find tag by slug | -| `findTagsByType(type)` | `Promise` | Get all tags of a type | -| `getAllTags()` | `Promise` | Get all tags | -| `deleteTag(id)` | `Promise` | Delete tag and pivots | -| `attachTag(Entity, entityId, tagId)` | `Promise` | Attach tag to entity | -| `detachTag(Entity, entityId, tagId)` | `Promise` | Detach tag from entity | -| `syncTags(Entity, entityId, tagIds)` | `Promise` | Sync entity tags | -| `getEntityTags(Entity, entityId)` | `Promise` | Get entity's tags | -| `hasTag(Entity, entityId, tagId)` | `Promise` | Check if entity has tag | -| `getEntitiesWithTag(Entity, tagId)` | `Promise` | Get entity IDs with tag | -| `getTagCount(Entity, entityId)` | `Promise` | Count entity's tags | - -## Configuration Options - -| Option | Type | Default | Description | -| ---------------- | -------- | ------------ | ------------------------------ | -| `tableName` | `string` | `"tags"` | Custom name for the tags table | -| `pivotTableName` | `string` | `"taggables"` | Custom name for the pivot table | +| Method | Returns | Description | +| ------------------------------------ | ---------------------------- | ----------------------- | +| `createTag(name, opts?)` | `Promise` | Create a new tag | +| `findOrCreateTag(name, opts?)` | `Promise` | Find by slug or create | +| `findTagById(id)` | `Promise` | Find tag by ID | +| `findTagBySlug(slug, type?)` | `Promise` | Find tag by slug | +| `findTagsByType(type)` | `Promise` | Get all tags of a type | +| `getAllTags()` | `Promise` | Get all tags | +| `deleteTag(id)` | `Promise` | Delete tag and pivots | +| `attachTag(Entity, entityId, tagId)` | `Promise` | Attach tag to entity | +| `detachTag(Entity, entityId, tagId)` | `Promise` | Detach tag from entity | +| `syncTags(Entity, entityId, tagIds)` | `Promise` | Sync entity tags | +| `getEntityTags(Entity, entityId)` | `Promise` | Get entity's tags | +| `hasTag(Entity, entityId, tagId)` | `Promise` | Check if entity has tag | +| `getEntitiesWithTag(Entity, tagId)` | `Promise` | Get entity IDs with tag | +| `getTagCount(Entity, entityId)` | `Promise` | Count entity's tags | +| `getOptions()` | `TaggableModuleOptions` | Get module options | ## Tag Entity The `tags` table stores: -| Column | Type | Description | -| ------------ | ------------ | ---------------------- | -| `id` | UUID | Primary key | -| `name` | varchar(255) | Tag name | -| `slug` | varchar(255) | URL-friendly slug | +| Column | Type | Description | +| ------------ | ------------ | ------------------------- | +| `id` | UUID | Primary key | +| `name` | varchar(255) | Tag name | +| `slug` | varchar(255) | URL-friendly slug | | `type` | varchar(100) | Tag type/group (nullable) | -| `metadata` | text | JSON metadata (nullable) | -| `created_at` | timestamp | Creation timestamp | -| `updated_at` | timestamp | Last update timestamp | +| `metadata` | text | JSON metadata (nullable) | +| `created_at` | timestamp | Creation timestamp | +| `updated_at` | timestamp | Last update timestamp | ## Testing @@ -351,7 +354,7 @@ If you discover any security-related issues, please report them via [GitHub Issu ## Credits -- Inspired by [Laravel's](https://laravel.com) tagging packages and [spatie/laravel-tags](https://github.com/spatie/laravel-tags) +- Built by [Nestbolt](https://github.com/nestbolt) ## License diff --git a/package.json b/package.json index 9671bac..12c94b7 100644 --- a/package.json +++ b/package.json @@ -41,9 +41,7 @@ "engines": { "node": ">=20.0.0" }, - "dependencies": { - "uuid": "^11.0.0" - }, + "dependencies": {}, "peerDependencies": { "@nestjs/common": "^10.0.0 || ^11.0.0", "@nestjs/core": "^10.0.0 || ^11.0.0", @@ -65,7 +63,6 @@ "@nestjs/typeorm": "^11.0.0", "@types/better-sqlite3": "^7.6.13", "@types/node": "^20.0.0", - "@types/uuid": "^10.0.0", "@vitest/coverage-v8": "^4.1.0", "better-sqlite3": "^12.8.0", "eslint": "^9.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cff9bb3..37c8a74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,9 +11,6 @@ importers: '@nestjs/event-emitter': specifier: ^2.0.0 || ^3.0.0 version: 3.0.1(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.18(@nestjs/common@11.1.18(reflect-metadata@0.2.2)(rxjs@7.8.2))(reflect-metadata@0.2.2)(rxjs@7.8.2)) - uuid: - specifier: ^11.0.0 - version: 11.1.0 devDependencies: '@eslint/js': specifier: ^9.0.0 @@ -36,9 +33,6 @@ importers: '@types/node': specifier: ^20.0.0 version: 20.19.39 - '@types/uuid': - specifier: ^10.0.0 - version: 10.0.0 '@vitest/coverage-v8': specifier: ^4.1.0 version: 4.1.4(vitest@4.1.4) @@ -390,9 +384,6 @@ packages: '@types/node@20.19.39': resolution: {integrity: sha512-orrrD74MBUyK8jOAD/r0+lfa1I2MO6I+vAkmAWzMYbCcgrN4lCrmK52gRFQq/JRxfYPfonkr4b0jcY7Olqdqbw==} - '@types/uuid@10.0.0': - resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} - '@typescript-eslint/eslint-plugin@8.58.1': resolution: {integrity: sha512-eSkwoemjo76bdXl2MYqtxg51HNwUSkWfODUOQ3PaTLZGh9uIWWFZIjyjaJnex7wXDu+TRx+ATsnSxdN9YWfRTQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -1875,8 +1866,6 @@ snapshots: dependencies: undici-types: 6.21.0 - '@types/uuid@10.0.0': {} - '@typescript-eslint/eslint-plugin@8.58.1(@typescript-eslint/parser@8.58.1(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 diff --git a/src/entities/tag.entity.ts b/src/entities/tag.entity.ts index 44f0d1c..bc0ab67 100644 --- a/src/entities/tag.entity.ts +++ b/src/entities/tag.entity.ts @@ -8,6 +8,7 @@ import { } from "typeorm"; @Entity("tags") +@Index(["slug", "type"], { unique: true }) export class TagEntity { @PrimaryGeneratedColumn("uuid") id!: string; diff --git a/src/interfaces/taggable-options.interface.ts b/src/interfaces/taggable-options.interface.ts index 033401d..7313fdd 100644 --- a/src/interfaces/taggable-options.interface.ts +++ b/src/interfaces/taggable-options.interface.ts @@ -1,12 +1,11 @@ import type { DynamicModule } from "@nestjs/common"; -export interface TaggableModuleOptions { - tableName?: string; - pivotTableName?: string; -} +export type TaggableModuleOptions = Record; export interface TaggableAsyncOptions { imports?: (DynamicModule | Promise | any)[]; - useFactory: (...args: any[]) => TaggableModuleOptions | Promise; + useFactory: ( + ...args: any[] + ) => TaggableModuleOptions | Promise; inject?: any[]; } diff --git a/src/taggable.service.ts b/src/taggable.service.ts index 26e0e50..ac535fe 100644 --- a/src/taggable.service.ts +++ b/src/taggable.service.ts @@ -1,13 +1,19 @@ -import "reflect-metadata"; -import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit, Optional } from "@nestjs/common"; +import { + Inject, + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, + Optional, +} from "@nestjs/common"; import { InjectRepository } from "@nestjs/typeorm"; -import { DataSource, Repository } from "typeorm"; -import { TAGGABLE_OPTIONS } from "./taggable.constants"; -import { TAGGABLE_METADATA_KEY } from "./taggable.constants"; -import { TAGGABLE_EVENTS } from "./events/taggable.events"; +import "reflect-metadata"; +import { Repository } from "typeorm"; import { TagEntity } from "./entities/tag.entity"; import { TaggableEntity } from "./entities/taggable.entity"; +import { TAGGABLE_EVENTS } from "./events/taggable.events"; import type { TaggableModuleOptions } from "./interfaces"; +import { TAGGABLE_METADATA_KEY, TAGGABLE_OPTIONS } from "./taggable.constants"; interface EventEmitterLike { emit(event: string, ...args: any[]): boolean; @@ -20,10 +26,13 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { constructor( @Inject(TAGGABLE_OPTIONS) private readonly options: TaggableModuleOptions, - @InjectRepository(TagEntity) private readonly tagRepo: Repository, - @InjectRepository(TaggableEntity) private readonly taggableRepo: Repository, - private readonly dataSource: DataSource, - @Optional() @Inject("EventEmitter2") private readonly eventEmitter?: EventEmitterLike, + @InjectRepository(TagEntity) + private readonly tagRepo: Repository, + @InjectRepository(TaggableEntity) + private readonly taggableRepo: Repository, + @Optional() + @Inject("EventEmitter2") + private readonly eventEmitter?: EventEmitterLike, ) {} onModuleInit(): void { @@ -49,7 +58,10 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { // --- Tag CRUD --- - async createTag(name: string, options?: { slug?: string; type?: string; metadata?: any }): Promise { + async createTag( + name: string, + options?: { slug?: string; type?: string; metadata?: any }, + ): Promise { const slug = options?.slug ?? this.slugify(name); const tag = this.tagRepo.create({ name, @@ -63,9 +75,14 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { return saved; } - async findOrCreateTag(name: string, options?: { type?: string }): Promise { + async findOrCreateTag( + name: string, + options?: { type?: string }, + ): Promise { const slug = this.slugify(name); - const qb = this.tagRepo.createQueryBuilder("t").where("t.slug = :slug", { slug }); + const qb = this.tagRepo + .createQueryBuilder("t") + .where("t.slug = :slug", { slug }); if (options?.type) { qb.andWhere("t.type = :type", { type: options.type }); @@ -107,7 +124,11 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { // --- Attach / Detach --- - async attachTag(entityConstructor: Function, entityId: string, tagId: string): Promise { + async attachTag( + entityConstructor: Function, + entityId: string, + tagId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); const existing = await this.taggableRepo.findOne({ @@ -122,13 +143,19 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { }); await this.taggableRepo.save(pivot); - const tag = await this.tagRepo.findOne({ where: { id: tagId } }); - if (tag) { - this.emit(TAGGABLE_EVENTS.TAG_ATTACHED, { tag, taggableType, taggableId: entityId }); - } + const tag = await this.tagRepo.findOneByOrFail({ id: tagId }); + this.emit(TAGGABLE_EVENTS.TAG_ATTACHED, { + tag, + taggableType, + taggableId: entityId, + }); } - async detachTag(entityConstructor: Function, entityId: string, tagId: string): Promise { + async detachTag( + entityConstructor: Function, + entityId: string, + tagId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); await this.taggableRepo.delete({ @@ -137,10 +164,18 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { taggableId: entityId, }); - this.emit(TAGGABLE_EVENTS.TAG_DETACHED, { tagId, taggableType, taggableId: entityId }); + this.emit(TAGGABLE_EVENTS.TAG_DETACHED, { + tagId, + taggableType, + taggableId: entityId, + }); } - async syncTags(entityConstructor: Function, entityId: string, tagIds: string[]): Promise { + async syncTags( + entityConstructor: Function, + entityId: string, + tagIds: string[], + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); const existing = await this.taggableRepo.find({ @@ -162,7 +197,10 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { // --- Query --- - async getEntityTags(entityConstructor: Function, entityId: string): Promise { + async getEntityTags( + entityConstructor: Function, + entityId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); const pivots = await this.taggableRepo.find({ @@ -173,7 +211,11 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { return pivots.map((p) => p.tag).filter(Boolean); } - async hasTag(entityConstructor: Function, entityId: string, tagId: string): Promise { + async hasTag( + entityConstructor: Function, + entityId: string, + tagId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); const count = await this.taggableRepo.count({ @@ -183,7 +225,10 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { return count > 0; } - async getEntitiesWithTag(entityConstructor: Function, tagId: string): Promise { + async getEntitiesWithTag( + entityConstructor: Function, + tagId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); const pivots = await this.taggableRepo.find({ @@ -193,7 +238,10 @@ export class TaggableService implements OnModuleInit, OnModuleDestroy { return pivots.map((p) => p.taggableId); } - async getTagCount(entityConstructor: Function, entityId: string): Promise { + async getTagCount( + entityConstructor: Function, + entityId: string, + ): Promise { const taggableType = this.resolveTaggableType(entityConstructor); return this.taggableRepo.count({ diff --git a/test/taggable.mixin.spec.ts b/test/taggable.mixin.spec.ts new file mode 100644 index 0000000..1d14f65 --- /dev/null +++ b/test/taggable.mixin.spec.ts @@ -0,0 +1,162 @@ +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 { TaggableModule } from "../src/taggable.module"; +import { TaggableService } from "../src/taggable.service"; +import { TagEntity } from "../src/entities/tag.entity"; +import { TaggableEntity } from "../src/entities/taggable.entity"; +import { Taggable } from "../src/decorators/taggable.decorator"; +import { TaggableMixin } from "../src/mixins/taggable.mixin"; +import { TaggableNotInitializedException } from "../src/exceptions/taggable-not-initialized.exception"; + +@Taggable({ type: "MixinPost" }) +@Entity("mixin_posts") +class MixinPost extends TaggableMixin(class { + id!: string; +}) { + @PrimaryGeneratedColumn("uuid") + declare id: string; + + @Column() + title!: string; +} + +@Entity("mixin_plain") +class MixinPlain extends TaggableMixin(class { + id!: string; +}) { + @PrimaryGeneratedColumn("uuid") + declare id: string; + + @Column() + name!: string; +} + +describe("TaggableMixin", () => { + let module: TestingModule; + let service: TaggableService; + let dataSource: DataSource; + + beforeEach(async () => { + module = await Test.createTestingModule({ + imports: [ + TypeOrmModule.forRoot({ + type: "better-sqlite3", + database: ":memory:", + entities: [MixinPost, MixinPlain, TagEntity, TaggableEntity], + synchronize: true, + }), + TaggableModule.forRoot(), + ], + }).compile(); + + await module.init(); + service = module.get(TaggableService); + dataSource = module.get(DataSource); + }); + + afterEach(async () => { + await module?.close(); + }); + + describe("getTaggableType()", () => { + it("should return custom type from @Taggable metadata", () => { + const post = new MixinPost(); + expect(post.getTaggableType()).toBe("MixinPost"); + }); + + it("should default to class name when no metadata", () => { + const plain = new MixinPlain(); + expect(plain.getTaggableType()).toBe("MixinPlain"); + }); + }); + + describe("getTaggableId()", () => { + it("should return the entity id as string", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = await repo.save(repo.create({ title: "Test" })); + expect(post.getTaggableId()).toBe(post.id); + }); + }); + + describe("attachTag() / detachTag()", () => { + it("should attach and detach tags via mixin", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = await repo.save(repo.create({ title: "Test" })); + const tag = await service.createTag("MixinTag"); + + await post.attachTag(tag.id); + let tags = await post.getTags(); + expect(tags).toHaveLength(1); + expect(tags[0].name).toBe("MixinTag"); + + await post.detachTag(tag.id); + tags = await post.getTags(); + expect(tags).toHaveLength(0); + }); + }); + + describe("syncTags()", () => { + it("should sync tags via mixin", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = await repo.save(repo.create({ title: "Sync" })); + const tag1 = await service.createTag("Sync1"); + const tag2 = await service.createTag("Sync2"); + const tag3 = await service.createTag("Sync3"); + + await post.attachTag(tag1.id); + await post.attachTag(tag2.id); + + await post.syncTags([tag2.id, tag3.id]); + + const tags = await post.getTags(); + const names = tags.map((t) => t.name).sort(); + expect(names).toEqual(["Sync2", "Sync3"]); + }); + }); + + describe("hasTag()", () => { + it("should check if entity has a tag", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = await repo.save(repo.create({ title: "Has" })); + const tag = await service.createTag("CheckTag"); + + expect(await post.hasTag(tag.id)).toBe(false); + await post.attachTag(tag.id); + expect(await post.hasTag(tag.id)).toBe(true); + }); + }); + + describe("getTagCount()", () => { + it("should return tag count", async () => { + const repo = dataSource.getRepository(MixinPost); + const post = await repo.save(repo.create({ title: "Count" })); + const tag1 = await service.createTag("C1"); + const tag2 = await service.createTag("C2"); + + expect(await post.getTagCount()).toBe(0); + await post.attachTag(tag1.id); + await post.attachTag(tag2.id); + expect(await post.getTagCount()).toBe(2); + }); + }); +}); + +describe("TaggableMixin without service", () => { + it("should throw TaggableNotInitializedException when service is not available", () => { + const post = new MixinPost(); + (post as any).id = "fake-id"; + expect(() => post.attachTag("fake-tag")).rejects.toThrow(TaggableNotInitializedException); + }); +}); + +describe("TaggableNotInitializedException", () => { + it("should have correct message and name", () => { + const error = new TaggableNotInitializedException(); + expect(error.message).toContain("TaggableModule has not been initialized"); + expect(error.name).toBe("TaggableNotInitializedException"); + expect(error).toBeInstanceOf(Error); + }); +}); diff --git a/test/taggable.service.spec.ts b/test/taggable.service.spec.ts index 7542367..55161a8 100644 --- a/test/taggable.service.spec.ts +++ b/test/taggable.service.spec.ts @@ -7,6 +7,7 @@ import { TaggableService } from "../src/taggable.service"; import { TagEntity } from "../src/entities/tag.entity"; import { TaggableEntity } from "../src/entities/taggable.entity"; import { Taggable } from "../src/decorators/taggable.decorator"; +import { TaggableModule } from "../src/taggable.module"; import { TAGGABLE_OPTIONS } from "../src/taggable.constants"; @Taggable({ type: "Post" }) @@ -297,4 +298,175 @@ describe("TaggableService", () => { expect(articleTags).toHaveLength(0); }); }); + + describe("getOptions()", () => { + it("should return the module options", () => { + const options = service.getOptions(); + expect(options).toBeDefined(); + expect(typeof options).toBe("object"); + }); + }); + + describe("findOrCreateTag() with type", () => { + it("should filter by type when provided", async () => { + await service.createTag("Same Name", { type: "category" }); + await service.createTag("Same Name", { type: "label", slug: "same-name-label" }); + + const found = await service.findOrCreateTag("Same Name", { type: "category" }); + expect(found.type).toBe("category"); + }); + }); + + describe("findTagBySlug() with type", () => { + it("should filter by type when provided", async () => { + await service.createTag("ByType", { type: "special" }); + const found = await service.findTagBySlug("bytype", "special"); + expect(found).not.toBeNull(); + expect(found!.type).toBe("special"); + }); + + it("should return null when type does not match", async () => { + await service.createTag("ByType2", { type: "special" }); + const found = await service.findTagBySlug("bytype2", "other"); + expect(found).toBeNull(); + }); + }); + + describe("deleteTag() nonexistent", () => { + it("should handle deleting a nonexistent tag", async () => { + await service.deleteTag("00000000-0000-0000-0000-000000000000"); + // No error thrown + }); + }); + + describe("static instance lifecycle", () => { + it("should clear instance on module destroy", async () => { + expect(TaggableService.getInstance()).toBe(service); + await module.close(); + expect(TaggableService.getInstance()).toBeNull(); + module = undefined as any; + }); + + it("should not clear instance if it was replaced by another", async () => { + const origInstance = TaggableService.getInstance(); + // Replace static instance with a different value + (TaggableService as any).instance = { fake: true }; + + // Destroy the module — onModuleDestroy should NOT clear since instance !== this + await module.close(); + expect(TaggableService.getInstance()).toEqual({ fake: true }); + + // Cleanup + (TaggableService as any).instance = null; + module = undefined as any; + }); + }); + + + describe("resolveTaggableType() without decorator", () => { + it("should fall back to class name when no @Taggable metadata", async () => { + // Use a plain class without @Taggable decorator + class PlainEntity { + id!: string; + } + + const tag = await service.createTag("PlainTag"); + const postRepo = dataSource.getRepository(Post); + const post = await postRepo.save(postRepo.create({ title: "Plain" })); + + // Calling with PlainEntity — resolveTaggableType falls back to class name + await service.attachTag(PlainEntity as any, post.id, tag.id); + const has = await service.hasTag(PlainEntity as any, post.id, tag.id); + expect(has).toBe(true); + }); + }); +}); + +describe("TaggableService with events", () => { + let module: TestingModule; + let service: TaggableService; + 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: [Post, Article, TagEntity, TaggableEntity], + synchronize: true, + }), + TypeOrmModule.forFeature([TagEntity, TaggableEntity]), + ], + providers: [ + { provide: TAGGABLE_OPTIONS, useValue: {} }, + { + provide: "EventEmitter2", + useValue: { + emit(event: string, payload: any) { + emitted.push({ event, payload }); + return true; + }, + }, + }, + TaggableService, + ], + }).compile(); + + await module.init(); + service = module.get(TaggableService); + dataSource = module.get(DataSource); + }); + + afterEach(async () => { + await module?.close(); + }); + + it("should emit tag.created event", async () => { + await service.createTag("EventTag"); + const evt = emitted.find((e) => e.event === "tag.created"); + expect(evt).toBeDefined(); + expect(evt!.payload.tag.name).toBe("EventTag"); + }); + + it("should emit tag.deleted event", async () => { + const tag = await service.createTag("DeleteMe"); + emitted.length = 0; + + await service.deleteTag(tag.id); + const evt = emitted.find((e) => e.event === "tag.deleted"); + expect(evt).toBeDefined(); + expect(evt!.payload.tagId).toBe(tag.id); + expect(evt!.payload.tagName).toBe("DeleteMe"); + }); + + it("should emit tag.attached event", async () => { + const tag = await service.createTag("AttachEvent"); + const postRepo = dataSource.getRepository(Post); + const post = await postRepo.save(postRepo.create({ title: "Evt Post" })); + emitted.length = 0; + + await service.attachTag(Post, post.id, tag.id); + const evt = emitted.find((e) => e.event === "tag.attached"); + expect(evt).toBeDefined(); + expect(evt!.payload.tag.name).toBe("AttachEvent"); + expect(evt!.payload.taggableType).toBe("Post"); + expect(evt!.payload.taggableId).toBe(post.id); + }); + + it("should emit tag.detached event", async () => { + const tag = await service.createTag("DetachEvent"); + const postRepo = dataSource.getRepository(Post); + const post = await postRepo.save(postRepo.create({ title: "Evt Post" })); + await service.attachTag(Post, post.id, tag.id); + emitted.length = 0; + + await service.detachTag(Post, post.id, tag.id); + const evt = emitted.find((e) => e.event === "tag.detached"); + expect(evt).toBeDefined(); + expect(evt!.payload.tagId).toBe(tag.id); + }); });