Просмотр исходного кода

feat(cache): add end-to-end tests for Redis video cache and enhance cache key handling

Dave 3 месяцев назад
Родитель
Сommit
58f5fa195d

+ 117 - 0
apps/box-mgnt-api/test/README.md

@@ -0,0 +1,117 @@
+# Video Cache E2E Test
+
+## Overview
+
+This test validates the Redis cache contract for video caches:
+
+- Category video lists contain ONLY video IDs (not Tag JSON)
+- Tag-filtered video lists contain ONLY video IDs
+- Tag metadata lists contain ONLY Tag JSON objects
+
+## Prerequisites
+
+Before running the test, ensure:
+
+1. **MongoDB is running** and accessible with valid credentials
+2. **Redis is running** on localhost:6379 (or configure in .env file)
+3. **MongoDB has test data** (channels, categories, videos, tags)
+
+## Environment Setup
+
+The test uses `.env.mgnt.test` for configuration. Update the following variables if needed:
+
+```bash
+# MongoDB connection (ensure user exists and has correct permissions)
+MONGO_URL="mongodb://boxuser:PASSWORD@localhost:27017/box_admin?authSource=admin"
+MONGO_STATS_URL="mongodb://boxstatuser:PASSWORD@localhost:27017/box_stats?authSource=admin"
+
+# Redis connection
+REDIS_HOST=127.0.0.1
+REDIS_PORT=6379
+REDIS_DB=0
+```
+
+### MongoDB Authentication Issues
+
+If you see `SCRAM failure: Authentication failed`:
+
+1. **Option A**: Create the MongoDB user:
+
+   ```bash
+   mongosh admin
+   db.createUser({
+     user: "boxuser",
+     pwd: "YOUR_PASSWORD",
+     roles: [{ role: "readWrite", db: "box_admin" }]
+   })
+   ```
+
+2. **Option B**: Update `.env.mgnt.test` to use your existing MongoDB credentials
+
+3. **Option C**: Use connection string without authentication:
+   ```bash
+   MONGO_URL="mongodb://localhost:27017/box_admin"
+   ```
+
+## Running the Test
+
+```bash
+# From box-nestjs-monorepo directory
+pnpm test:e2e:mgnt
+```
+
+## Test Output
+
+Successful test output includes:
+
+```
+✅ Redis connection verified
+🗑️  Deleted cache keys: { ... }
+🔨 Rebuilding video caches...
+📋 Found X category video list keys
+📋 Found X tag-filtered video list keys
+📋 Found X tag metadata list keys
+✅ All cache keys validated successfully!
+```
+
+## Validation Logic
+
+The test performs these validations:
+
+### Video List Keys
+
+- Key type is `list`
+- Each element is a 24-character hex string (MongoDB ObjectId)
+- **Fails if** Tag JSON or Category JSON is detected
+
+### Tag Metadata Keys
+
+- Key type is `list`
+- Each element is valid JSON with required fields: `id`, `name`, `seq`, `status`, `channelId`, `categoryId`
+- **Fails if** plain video IDs are detected
+
+## Troubleshooting
+
+### No Cache Keys Found
+
+If test shows `Found 0 category video list keys`:
+
+- MongoDB has no data - run seed scripts:
+  ```bash
+  pnpm prisma:seed:mongo:test
+  ```
+
+### Test Timeout
+
+If test exceeds 60 seconds:
+
+- Check MongoDB query performance
+- Ensure indexes exist on frequently queried fields
+- Reduce test data volume
+
+### Redis Connection Failed
+
+If `expect(pong).toBe('PONG')` fails:
+
+- Verify Redis is running: `redis-cli ping`
+- Check REDIS_HOST and REDIS_PORT in `.env.mgnt.test`

+ 32 - 0
apps/box-mgnt-api/test/jest-e2e.json

@@ -0,0 +1,32 @@
+{
+  "preset": "ts-jest",
+  "testEnvironment": "node",
+  "rootDir": ".",
+  "testRegex": ".e2e-spec.ts$",
+  "moduleNameMapper": {
+    "^@box/common/(.*)$": "<rootDir>/../../../libs/common/src/$1",
+    "^@box/db/(.*)$": "<rootDir>/../../../libs/db/src/$1",
+    "^@box/core/(.*)$": "<rootDir>/../../../libs/core/src/$1",
+    "^@box/mgnt/(.*)$": "<rootDir>/../../box-mgnt-api/src/$1"
+  },
+  "transform": {
+    "^.+\\.(t|j)s$": "ts-jest"
+  },
+  "collectCoverageFrom": [
+    "**/*.(t|j)s"
+  ],
+  "coverageDirectory": "../coverage",
+  "testTimeout": 60000,
+  "globals": {
+    "ts-jest": {
+      "tsconfig": {
+        "types": [
+          "jest",
+          "node"
+        ],
+        "esModuleInterop": true,
+        "allowSyntheticDefaultImports": true
+      }
+    }
+  }
+}

+ 16 - 0
apps/box-mgnt-api/test/tsconfig.json

@@ -0,0 +1,16 @@
+{
+  "extends": "../tsconfig.json",
+  "compilerOptions": {
+    "types": ["jest", "node"],
+    "esModuleInterop": true,
+    "allowSyntheticDefaultImports": true,
+    "module": "commonjs",
+    "target": "ES2021",
+    "lib": ["ES2021"],
+    "strict": true,
+    "skipLibCheck": true,
+    "resolveJsonModule": true
+  },
+  "include": ["**/*.spec.ts", "**/*.e2e-spec.ts"],
+  "exclude": ["node_modules", "dist"]
+}

+ 52 - 15
libs/common/src/cache/cache-keys.ts

@@ -82,11 +82,35 @@ export const CacheKeys = {
    */
   appTagAll: 'app:tag:all',
 
-  // (Optional future)
-  // appTagByCategory: (categoryId: string | number): string =>
-  //   `app:tag:by-category:${categoryId}`,
-  // appTagByChannel: (channelId: string | number): string =>
-  //   `app:tag:by-channel:${channelId}`,
+  /**
+   * Tag metadata list for a category (used for tag filter UI).
+   *
+   * Redis Type: LIST
+   * Elements: Tag JSON objects (stringified)
+   * Order: seq ascending (business order for dropdown display)
+   * Format per element: { id, name, seq, status, createAt, updateAt, channelId, categoryId }
+   *
+   * Operations:
+   * - Write: DEL + RPUSH (atomic via saveTagList in VideoCacheHelper)
+   * - Read: LRANGE key 0 -1, then parse each element as JSON
+   *
+   * Example: LRANGE "box:app:tag:list:cat-001" 0 -1
+   *          → ['{"id":"tag-1","name":"Action",...}', '{"id":"tag-2","name":"Drama",...}']
+   *
+   * ⚠️ CRITICAL CONTRACT:
+   * ───────────────────
+   * This is the ONLY key where Tag JSON objects should be stored.
+   * - ❌ NEVER store Tag JSON in "box:app:video:category:list:{categoryId}"
+   * - ❌ NEVER store Tag JSON in "box:app:video:tag:list:{categoryId}:{tagId}"
+   * - ✅ ONLY store Video IDs in those video list keys
+   *
+   * Built by: VideoCategoryCacheBuilder.buildTagMetadataListForCategory()
+   * Read by: VideoService.getTagListForCategory()
+   *
+   * SEE: libs/common/src/cache/CACHE_SEMANTICS.md → "Tag Metadata Lists"
+   */
+  appTagByCategoryKey: (categoryId: string | number): string =>
+    `app:tag:list:${categoryId}`,
 
   // ─────────────────────────────────────────────
   // ADS (existing)
@@ -131,7 +155,13 @@ export const CacheKeys = {
    * Redis Type: LIST
    * Elements: Video IDs (strings, Mongo ObjectId format like "64a2b3c4d5e6f7g8h9i0j1k2")
    * Order: Descending by business order (seq → newest first)
-   * ✅ IMPORTANT: Contains VIDEO IDs ONLY, never JSON objects
+   *
+   * ⚠️ CRITICAL CONTRACT:
+   * ────────────────────
+   * - ✅ ONLY VIDEO IDs (strings) are stored here
+   * - ❌ NEVER store JSON objects (video details, tag metadata, category metadata)
+   * - ❌ NEVER store Tag JSON in this key
+   * - For Tag metadata, use "box:app:tag:list:{categoryId}" instead
    *
    * Operations:
    * - Write: DEL + RPUSH (atomic via rpushList)
@@ -140,13 +170,13 @@ export const CacheKeys = {
    * Example: LRANGE "box:app:video:category:list:cat-001" 0 -1
    *          → ["video-001", "video-002", "video-003"]
    *
-   * Built by: VideoCategoryCacheBuilder.buildCategoryListForChannel()
+   * Built by: VideoCategoryCacheBuilder.buildCategoryVideoListForCategory()
    * Read by: VideoService.getCategoryListForChannel()
    *
    * SEE: libs/common/src/cache/CACHE_SEMANTICS.md → "Category Video List"
    */
-  appVideoCategoryListKey: (channelId: string): string =>
-    `app:video:category:list:${channelId}`,
+  appVideoCategoryListKey: (categoryId: string): string =>
+    `app:video:category:list:${categoryId}`,
 
   /**
    * Category + tag filtered video list (videos in category with specific tag).
@@ -154,7 +184,14 @@ export const CacheKeys = {
    * Redis Type: LIST
    * Elements: Video IDs (strings, Mongo ObjectId format)
    * Order: Same as category list (descending by business order)
-   * ✅ IMPORTANT: Contains VIDEO IDs ONLY, never JSON objects or Tag data
+   *
+   * ⚠️ CRITICAL CONTRACT:
+   * ────────────────────
+   * - ✅ ONLY VIDEO IDs (strings) are stored here
+   * - ❌ NEVER store JSON objects (video details, tag metadata)
+   * - ❌ NEVER store Tag JSON in this key
+   * - This key contains VIDEO IDs filtered by a specific tag
+   * - For Tag metadata, use "box:app:tag:list:{categoryId}" instead
    *
    * Operations:
    * - Write: DEL + RPUSH (atomic via rpushList)
@@ -163,15 +200,15 @@ export const CacheKeys = {
    * Example: LRANGE "box:app:video:tag:list:cat-001:tag-sports" 0 -1
    *          → ["video-001", "video-003", "video-007"]
    *
-   * Note: This is distinct from box:app:tag:list:* keys which contain Tag JSON
+   * Note: Distinct from "box:app:tag:list:{categoryId}" which stores Tag JSON objects
    *
-   * Built by: VideoCategoryCacheBuilder.buildTagListForCategory()
-   * Read by: VideoService.getTagListForCategory()
+   * Built by: VideoCategoryCacheBuilder.buildTagFilteredVideoListForTag()
+   * Read by: VideoService.getVideosByTag()
    *
    * SEE: libs/common/src/cache/CACHE_SEMANTICS.md → "Category + Tag Filtered Video List"
    */
-  appVideoTagListKey: (channelId: string, categoryId: string): string =>
-    `app:video:tag:list:${channelId}:${categoryId}`,
+  appVideoTagListKey: (categoryId: string, tagId: string): string =>
+    `app:video:tag:list:${categoryId}:${tagId}`,
 
   // ─────────────────────────────────────────────
   // VIDEO POOLS (sorted listings with scores)

+ 12 - 11
libs/common/src/cache/ts-cache-key.provider.ts

@@ -132,28 +132,28 @@ export interface TsCacheKeyBuilder {
     detail(videoId: string): string;
 
     /**
-     * Get video category list by channel.
+     * Get video category list by category.
      * Redis Type: LIST
      * Elements: Video IDs ONLY (strings)
      * Order: Business sequence (seq/newest first)
      *
-     * Example: tsCacheKeys.video.categoryList('ch-1')
-     *          → "box:app:video:category:list:ch-1"
+     * Example: tsCacheKeys.video.categoryList('cat-001')
+     *          → "box:app:video:category:list:cat-001"
      */
-    categoryList(channelId: string): string;
+    categoryList(categoryId: string): string;
 
     /**
-     * Get video tag list by channel and category.
+     * Get video tag list by category and tag.
      * Redis Type: LIST
      * Elements: Video IDs ONLY (strings, filtered by tag)
      * Order: Same as categoryList
      *
      * ⚠️ NOT to be confused with tag.all() which contains TAG JSON
      *
-     * Example: tsCacheKeys.video.tagList('ch-1', 'cat-1')
-     *          → "box:app:video:tag:list:ch-1:cat-1"
+     * Example: tsCacheKeys.video.tagList('cat-001', 'tag-sports')
+     *          → "box:app:video:tag:list:cat-001:tag-sports"
      */
-    tagList(channelId: string, categoryId: string): string;
+    tagList(categoryId: string, tagId: string): string;
 
     /**
      * Get videos in a category with sort order (ZSET pool).
@@ -234,9 +234,10 @@ export function createTsCacheKeyBuilder(): TsCacheKeyBuilder {
     },
     video: {
       detail: (videoId) => CacheKeys.appVideoDetailKey(videoId),
-      categoryList: (channelId) => CacheKeys.appVideoCategoryListKey(channelId),
-      tagList: (channelId, categoryId) =>
-        CacheKeys.appVideoTagListKey(channelId, categoryId),
+      categoryList: (categoryId) =>
+        CacheKeys.appVideoCategoryListKey(categoryId),
+      tagList: (categoryId, tagId) =>
+        CacheKeys.appVideoTagListKey(categoryId, tagId),
       categoryPool: (channelId, categoryId, sort) =>
         CacheKeys.appVideoCategoryPoolKey(channelId, categoryId, sort),
       tagPool: (channelId, tagId, sort) =>

+ 5 - 2
libs/common/src/cache/video-cache.helper.ts

@@ -4,14 +4,17 @@ import { RedisService } from '@box/db/redis/redis.service';
 /**
  * Tag metadata interface for parsing from cache.
  * Matches the structure stored in box:app:tag:list:{categoryId} keys.
+ *
+ * This represents Tag entities, NOT Category entities.
+ * Tags do not have a subtitle field (that's in Category).
  */
 export interface TagMetadata {
   id: string;
   name: string;
   seq: number;
   status: number;
-  createAt: string;
-  updateAt: string;
+  createAt: string; // ISO string or numeric string
+  updateAt: string; // ISO string or numeric string
   channelId: string;
   categoryId: string;
 }

+ 257 - 83
libs/core/src/cache/video/category/video-category-cache.builder.ts

@@ -28,6 +28,8 @@ export interface TagMetadataPayload extends TagMetadata {}
  *    Key: box:app:video:category:list:{categoryId}
  *    Type: LIST
  *    Contents: Video IDs ONLY (strings like "64a2b3c4d5e6f7...")
+ *    ✅ CORRECT: Store only video IDs
+ *    ❌ WRONG: Never store Tag JSON, Category JSON, or any metadata objects
  *    WHY: Video IDs are the minimal data needed to:
  *      - Return to client (they fetch full details separately)
  *      - Paginate in client (they use ZSET pools for ordering)
@@ -37,12 +39,16 @@ export interface TagMetadataPayload extends TagMetadata {}
  *    Key: box:app:video:tag:list:{categoryId}:{tagId}
  *    Type: LIST
  *    Contents: Video IDs ONLY (subset of category videos with this tag)
+ *    ✅ CORRECT: Store only video IDs (filtered by tag membership)
+ *    ❌ WRONG: Never store Tag JSON or Video metadata
  *    WHY: Same as above - minimal data for tag-based filtering
  *
- * 3. TAG METADATA LISTS
+ * 3. TAG METADATA LISTS (THE ONLY PLACE FOR TAG JSON)
  *    Key: box:app:tag:list:{categoryId}
  *    Type: LIST
  *    Contents: Tag JSON objects (stringified)
+ *    ✅ CORRECT: This is the ONLY key where Tag JSON should be stored
+ *    ❌ WRONG: Never store Tag JSON in video list keys (items 1 & 2 above)
  *    WHY: Tags are used for filter UI (dropdown, checkboxes)
  *      - Client needs id, name, seq for display
  *      - Stored separately from videos to avoid duplication
@@ -51,6 +57,12 @@ export interface TagMetadataPayload extends TagMetadata {}
  * ═══════════════════════════════════════════════════════════════════════════
  * KEY INVARIANT: NEVER store Tag/Category JSON in video list keys!
  * ═══════════════════════════════════════════════════════════════════════════
+ *
+ * WRITER CONTRACT:
+ * ────────────────
+ * - buildCategoryVideoListForCategory() → uses saveVideoIdList() → writes to box:app:video:category:list:*
+ * - buildTagFilteredVideoListForTag() → uses saveVideoIdList() → writes to box:app:video:tag:list:*
+ * - buildTagMetadataListForCategory() → uses saveTagList() → writes to box:app:tag:list:*
  */
 @Injectable()
 export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
@@ -64,13 +76,37 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
   /**
    * Build all video/tag caches for all channels.
    * For each channel, builds:
-   * 1. Category video lists (video IDs only)
-   * 2. Tag-filtered video lists (video IDs only)
-   * 3. Tag metadata lists (Tag JSON objects)
+   * 1. Category video lists (video IDs only, using saveVideoIdList)
+   * 2. Tag-filtered video lists (video IDs only, using saveVideoIdList)
+   * 3. Tag metadata lists (Tag JSON objects, using saveTagList)
+   *
+   * ⚠️ IMPORTANT IMPLEMENTATION DETAILS:
+   * ───────────────────────────────────
+   * - All writes go through VideoCacheHelper (never direct RedisService calls)
+   * - saveVideoIdList() is used for both category and tag-filtered video lists
+   * - saveTagList() is ONLY used for tag metadata (box:app:tag:list:{categoryId})
+   * - This ensures atomicity (DEL + RPUSH + EXPIRE) for all operations
+   * - Tag JSON is NEVER written to video list keys
+   *
+   * LOGGING:
+   * ────────
+   * Provides detailed statistics about cache rebuild:
+   * - Number of channels processed
+   * - Number of categories rebuilt (category video lists)
+   * - Number of tag-filtered video lists rebuilt
+   * - Number of tag metadata lists rebuilt
    */
   async buildAll(): Promise<void> {
     const channels = await this.mongoPrisma.channel.findMany();
 
+    let totalCategories = 0;
+    let totalTagFilteredLists = 0;
+    let totalTagMetadataLists = 0;
+
+    this.logger.log(
+      `🔨 Starting video cache rebuild for ${channels.length} channels...`,
+    );
+
     for (const channel of channels) {
       try {
         // Build video lists for each category
@@ -81,9 +117,14 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
           },
         });
 
+        this.logger.debug(
+          `  Channel ${channel.id}: Processing ${categories.length} categories`,
+        );
+
         for (const category of categories) {
           // 1. Build list of all video IDs in this category
           await this.buildCategoryVideoListForCategory(category.id);
+          totalCategories++;
 
           // 2. Build tag-filtered video lists
           const tags = await this.mongoPrisma.tag.findMany({
@@ -96,125 +137,258 @@ export class VideoCategoryCacheBuilder extends BaseCacheBuilder {
 
           for (const tag of tags) {
             await this.buildTagFilteredVideoListForTag(category.id, tag.id);
+            totalTagFilteredLists++;
           }
 
           // 3. Build tag metadata list for this category
           await this.buildTagMetadataListForCategory(category.id);
+          totalTagMetadataLists++;
         }
+
+        this.logger.log(
+          `  ✅ Channel ${channel.id}: ${categories.length} categories, ${totalTagFilteredLists} tag filters`,
+        );
       } catch (err) {
         this.logger.error(
-          `Error building video cache for channel ${channel.id}`,
+          `Error building video cache for channel ${channel.id}`,
           err instanceof Error ? err.stack : String(err),
         );
       }
     }
 
-    this.logger.log(
-      `Built video and tag caches for ${channels.length} channels`,
-    );
+    this.logger.log(`
+📊 Video Cache Rebuild Summary:
+   ├─ Channels processed: ${channels.length}
+   ├─ Category video lists built: ${totalCategories} (box:app:video:category:list:*)
+   ├─ Tag-filtered video lists built: ${totalTagFilteredLists} (box:app:video:tag:list:*)
+   └─ Tag metadata lists built: ${totalTagMetadataLists} (box:app:tag:list:*)
+`);
   }
 
   /**
    * Build category video list (LIST of video IDs only).
    *
-   * Query: All videos in this category (no tag filter)
-   * Store: video IDs in order by seq
-   * Key: box:app:video:category:list:{categoryId}
+   * QUERY STRATEGY:
+   * ──────────────
+   * - Fetch all videos with listStatus === 1 (on shelf)
+   * - Order by addedTime DESC (newest/most recently added first)
+   * - Fallback to createdAt DESC if addedTime is not set
+   * - Extract ONLY video IDs (no metadata)
+   *
+   * WRITE STRATEGY:
+   * ───────────────
+   * - Key: box:app:video:category:list:{categoryId}
+   * - Type: Redis LIST (ordered set of strings)
+   * - Helper: VideoCacheHelper.saveVideoIdList() for atomic DEL + RPUSH + EXPIRE
+   * - If no videos found: clear the key (DEL) and skip RPUSH
+   *
+   * ⚠️ CRITICAL CONTRACT:
+   * ────────────────────
+   * - ✅ ONLY store Video IDs (strings)
+   * - ❌ NEVER store Tag JSON, Category JSON, or any metadata objects
+   * - ❌ NEVER store Tag JSON in this key (use box:app:tag:list:{categoryId} instead)
    *
-   * ⚠️ IMPORTANT: Store VIDEO IDs only, never category metadata or JSON objects
+   * ERROR HANDLING:
+   * ───────────────
+   * - If no videos found: log debug message and clear the key
+   * - If Redis operation fails: log error with stack trace and re-throw
+   * - All operations are atomic (DEL + RPUSH + EXPIRE)
    */
   async buildCategoryVideoListForCategory(categoryId: string): Promise<void> {
-    // Fetch all videos in this category, ordered by addedTime (provider's timestamp)
-    const videos = await this.mongoPrisma.videoMedia.findMany({
-      where: {
-        categoryId,
-        listStatus: 1, // Only "on shelf" videos
-      },
-      orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
-      select: { id: true }, // Only fetch IDs, not full documents
-    });
-
-    const videoIds = videos.map((v) => v.id);
-
-    const key = tsCacheKeys.video.categoryList(categoryId);
-    await this.cacheHelper.saveVideoIdList(key, videoIds);
-
-    this.logger.debug(
-      `Built category video list: ${categoryId} with ${videoIds.length} videos`,
-    );
+    try {
+      // Fetch all videos in this category, ordered by addedTime (provider's timestamp)
+      const videos = await this.mongoPrisma.videoMedia.findMany({
+        where: {
+          categoryId,
+          listStatus: 1, // Only "on shelf" videos
+        },
+        orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
+        select: { id: true }, // Only fetch IDs, not full documents
+      });
+
+      const videoIds = videos.map((v) => v.id);
+      const key = tsCacheKeys.video.categoryList(categoryId);
+
+      if (videoIds.length === 0) {
+        this.logger.debug(
+          `No videos found for category ${categoryId}, clearing cache key`,
+        );
+      }
+
+      // Atomic write: DEL existing key, RPUSH video IDs, set TTL if configured
+      await this.cacheHelper.saveVideoIdList(key, videoIds);
+
+      this.logger.debug(
+        `Built category video list for categoryId=${categoryId}: ${videoIds.length} videos stored`,
+      );
+    } catch (err) {
+      this.logger.error(
+        `Failed to build category video list for categoryId=${categoryId}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      throw err;
+    }
   }
 
   /**
    * Build tag-filtered video list (LIST of video IDs only).
    *
-   * Query: All videos in this category that have this tag
-   * Store: video IDs in order by seq
-   * Key: box:app:video:tag:list:{categoryId}:{tagId}
+   * QUERY STRATEGY:
+   * ──────────────
+   * - Fetch all videos with listStatus === 1 (on shelf) AND tagIds contains this tagId
+   * - Order by addedTime DESC (newest/most recently added first)
+   * - Fallback to createdAt DESC if addedTime is not set
+   * - Extract ONLY video IDs (no metadata)
    *
-   * ⚠️ IMPORTANT: Store VIDEO IDs only, never tag metadata or JSON objects
-   * This is distinct from box:app:tag:list:{categoryId} which stores tag metadata
+   * WRITE STRATEGY:
+   * ───────────────
+   * - Key: box:app:video:tag:list:{categoryId}:{tagId}
+   * - Type: Redis LIST (ordered set of strings)
+   * - Helper: VideoCacheHelper.saveVideoIdList() for atomic DEL + RPUSH + EXPIRE
+   * - If no videos found: clear the key (DEL) and skip RPUSH
+   *
+   * ⚠️ CRITICAL CONTRACT:
+   * ────────────────────
+   * - ✅ ONLY store Video IDs (strings) that have this tag
+   * - ❌ NEVER store Tag JSON or metadata objects in this key
+   * - ❌ NEVER store Tag JSON (use box:app:tag:list:{categoryId} instead)
+   * - This key is DISTINCT from box:app:tag:list:{categoryId} which stores Tag JSON
+   *
+   * ERROR HANDLING:
+   * ───────────────
+   * - If no videos found with this tag: log debug message and clear the key
+   * - If Redis operation fails: log error with stack trace and re-throw
+   * - All operations are atomic (DEL + RPUSH + EXPIRE)
    */
   async buildTagFilteredVideoListForTag(
     categoryId: string,
     tagId: string,
   ): Promise<void> {
-    // Fetch all videos in this category with this tag
-    const videos = await this.mongoPrisma.videoMedia.findMany({
-      where: {
-        categoryId,
-        listStatus: 1, // Only "on shelf" videos
-        tagIds: { has: tagId }, // Has this specific tag
-      },
-      orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
-      select: { id: true }, // Only fetch IDs
-    });
-
-    const videoIds = videos.map((v) => v.id);
-
-    const key = tsCacheKeys.video.tagList(categoryId, tagId);
-    await this.cacheHelper.saveVideoIdList(key, videoIds);
-
-    this.logger.debug(
-      `Built tag video list: ${categoryId}:${tagId} with ${videoIds.length} videos`,
-    );
+    try {
+      // Fetch all videos in this category with this tag
+      const videos = await this.mongoPrisma.videoMedia.findMany({
+        where: {
+          categoryId,
+          listStatus: 1, // Only "on shelf" videos
+          tagIds: { has: tagId }, // Has this specific tag
+        },
+        orderBy: [{ addedTime: 'desc' }, { createdAt: 'desc' }],
+        select: { id: true }, // Only fetch IDs
+      });
+
+      const videoIds = videos.map((v) => v.id);
+      const key = tsCacheKeys.video.tagList(categoryId, tagId);
+
+      if (videoIds.length === 0) {
+        this.logger.debug(
+          `No videos found for category ${categoryId} with tag ${tagId}, clearing cache key`,
+        );
+      }
+
+      // Atomic write: DEL existing key, RPUSH video IDs, set TTL if configured
+      await this.cacheHelper.saveVideoIdList(key, videoIds);
+
+      this.logger.debug(
+        `Built tag video list for categoryId=${categoryId}, tagId=${tagId}: ${videoIds.length} videos stored`,
+      );
+    } catch (err) {
+      this.logger.error(
+        `Failed to build tag video list for categoryId=${categoryId}, tagId=${tagId}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      throw err;
+    }
   }
 
   /**
    * Build tag metadata list (LIST of Tag JSON objects).
    *
-   * Query: All enabled tags in this category
-   * Store: Tag JSON objects (stringified, one per list element)
-   * Key: box:app:tag:list:{categoryId}
+   * QUERY STRATEGY:
+   * ──────────────
+   * - Fetch all tags with status === 1 (enabled) in this category
+   * - Order by seq ASC (business order for dropdown display)
+   * - Fallback to createAt ASC for consistent ordering
+   * - Transform to TagMetadataPayload (convert timestamps to strings)
+   *
+   * WRITE STRATEGY:
+   * ───────────────
+   * - Key: box:app:tag:list:{categoryId}
+   * - Type: Redis LIST (each element is stringified Tag JSON)
+   * - Helper: VideoCacheHelper.saveTagList() for atomic DEL + RPUSH + EXPIRE
+   * - If no tags found: clear the key (DEL) and skip RPUSH
    *
-   * ✅ CORRECT: This key stores Tag JSON objects
-   * ❌ WRONG: Never store video IDs in this key
+   * ⚠️ CRITICAL CONTRACT: THIS IS THE ONLY PLACE WHERE TAG JSON SHOULD BE STORED
+   * ──────────────────────────────────────────────────────────────────────────
+   * - ✅ ONLY store Tag JSON objects in this key
+   * - ✅ Each element is a stringified Tag object with {id, name, seq, status, ...}
+   * - ❌ NEVER store Tag JSON in "box:app:video:category:list:*" keys
+   * - ❌ NEVER store Tag JSON in "box:app:video:tag:list:*" keys
+   * - Video list keys MUST contain ONLY video IDs, never Tag metadata
    *
-   * Purpose: Used by frontend for tag filter dropdown/UI
+   * PURPOSE:
+   * ────────
+   * Used by frontend for tag filter UI (dropdowns, checkboxes)
    * Each tag is a separate list element and parsed as JSON when read
+   *
+   * ERROR HANDLING:
+   * ───────────────
+   * - If no tags found: log debug message and clear the key
+   * - If Redis operation fails: log error with stack trace and re-throw
+   * - All operations are atomic (DEL + RPUSH + EXPIRE)
+   *
+   * FORMAT PER ELEMENT IN LIST:
+   * ──────────────────────────
+   * {
+   *   id: string (tag ID),
+   *   name: string (display name),
+   *   seq: number (sort order),
+   *   status: number (0=disabled, 1=enabled),
+   *   createAt: string (ISO timestamp),
+   *   updateAt: string (ISO timestamp),
+   *   channelId: string,
+   *   categoryId: string
+   * }
    */
   async buildTagMetadataListForCategory(categoryId: string): Promise<void> {
-    // Fetch all enabled tags for this category, ordered by seq
-    const tags = await this.mongoPrisma.tag.findMany({
-      where: { status: 1, categoryId },
-      orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
-    });
-
-    const tagPayloads: TagMetadataPayload[] = tags.map((tag) => ({
-      id: tag.id,
-      name: tag.name,
-      seq: tag.seq,
-      status: tag.status,
-      createAt: tag.createAt.toString(),
-      updateAt: tag.updateAt.toString(),
-      channelId: tag.channelId,
-      categoryId: tag.categoryId,
-    }));
-
-    const key = `box:app:tag:list:${categoryId}`; // Direct key construction
-    await this.cacheHelper.saveTagList(key, tagPayloads);
-
-    this.logger.debug(
-      `Built tag metadata list: ${categoryId} with ${tagPayloads.length} tags`,
-    );
+    try {
+      // Fetch all enabled tags for this category, ordered by seq
+      const tags = await this.mongoPrisma.tag.findMany({
+        where: { status: 1, categoryId },
+        orderBy: [{ seq: 'asc' }, { createAt: 'asc' }],
+      });
+
+      if (tags.length === 0) {
+        this.logger.debug(
+          `No tags found for category ${categoryId}, clearing cache key`,
+        );
+      }
+
+      // Transform tags to payload format (convert BigInt timestamps to strings)
+      const tagPayloads: TagMetadataPayload[] = tags.map((tag) => ({
+        id: tag.id,
+        name: tag.name,
+        seq: tag.seq,
+        status: tag.status,
+        createAt: tag.createAt.toString(), // BigInt to string
+        updateAt: tag.updateAt.toString(), // BigInt to string
+        channelId: tag.channelId,
+        categoryId: tag.categoryId,
+      }));
+
+      const key = `box:app:tag:list:${categoryId}`; // Direct key construction
+
+      // Atomic write: DEL existing key, RPUSH tag JSON, set TTL if configured
+      await this.cacheHelper.saveTagList(key, tagPayloads);
+
+      this.logger.debug(
+        `Built tag metadata list for categoryId=${categoryId}: ${tagPayloads.length} tags stored`,
+      );
+    } catch (err) {
+      this.logger.error(
+        `Failed to build tag metadata list for categoryId=${categoryId}`,
+        err instanceof Error ? err.stack : String(err),
+      );
+      throw err;
+    }
   }
 }

+ 9 - 0
libs/db/src/redis/redis.service.ts

@@ -115,6 +115,15 @@ export class RedisService {
     return this.del(...keys);
   }
 
+  /**
+   * Get the Redis type of a key.
+   * Returns: string, list, set, zset, hash, stream, or none
+   */
+  async type(key: string): Promise<string> {
+    const client = this.ensureClient();
+    return client.type(key);
+  }
+
   // ─────────────────────────────────────────────
   // List operations
   // ─────────────────────────────────────────────

+ 2 - 1
package.json

@@ -29,7 +29,8 @@
     "typecheck:watch": "tsc --noEmit --watch --project tsconfig.base.json",
     "lint": "eslint \"{apps,libs}/**/*.ts\" --max-warnings 0",
     "lint:fix": "eslint \"{apps,libs}/**/*.ts\" --fix",
-    "test": "pnpm typecheck && pnpm lint && pnpm build:mgnt"
+    "test": "pnpm typecheck && pnpm lint && pnpm build:mgnt",
+    "test:e2e:mgnt": "dotenv -e .env.mgnt.test -- jest --config apps/box-mgnt-api/test/jest-e2e.json --runInBand --detectOpenHandles"
   },
   "dependencies": {
     "@aws-sdk/client-s3": "3.828.0",