diff --git a/bin/README.md b/bin/README.md index 95b1b8a7..0cd2b1ee 100644 --- a/bin/README.md +++ b/bin/README.md @@ -26,3 +26,59 @@ npm run serve # Process messages ``` **See:** [SQS Setup Guide](../docs/sqs-setup.md) for detailed SQS configuration. + +--- + +## `populateSdk.ts` + +One-time script to backfill the `sdk` column in the database by fetching `runtimeVersion` from scene.json files via Catalyst (for Genesis City places) and Worlds Content Server (for worlds). + +### Usage + +```bash +# Development environment +npm run populate:sdk:dev + +# Staging environment +npm run populate:sdk:stg + +# Production environment +npm run populate:sdk:prd +``` + +### Options + +| Option | Description | +| ----------- | -------------------------------------------------- | +| `--dry-run` | Preview changes without modifying the database | +| `--limit N` | Process only N records | +| `--places` | Process only Genesis City places (excludes worlds) | +| `--worlds` | Process only worlds (excludes places) | + +### Examples + +```bash +# Dry run with limit of 10 records in development +npm run populate:sdk:dev -- --dry-run --limit 10 + +# Process only worlds in staging +npm run populate:sdk:stg -- --worlds + +# Full production run (use with caution) +npm run populate:sdk:prd +``` + +### Environment Variables + +| Variable | Description | Default | +| --------------------------- | ---------------------------- | ------------------------------------------------ | +| `CONNECTION_STRING` | PostgreSQL connection string | (required) | +| `CATALYST_URL` | Catalyst server URL | `https://peer.decentraland.org` | +| `WORLDS_CONTENT_SERVER_URL` | Worlds Content Server URL | `https://worlds-content-server.decentraland.org` | + +### How It Works + +1. Queries database for records with `sdk IS NULL` +2. For Genesis City places: Fetches scene entities from Catalyst API and extracts `runtimeVersion` from scene.json +3. For Worlds: Fetches world metadata from Worlds Content Server using world name/URN +4. Updates database records with the extracted SDK version diff --git a/bin/populateSdk.ts b/bin/populateSdk.ts new file mode 100644 index 00000000..43eb9431 --- /dev/null +++ b/bin/populateSdk.ts @@ -0,0 +1,435 @@ +/** + * One-time script to populate the SDK column for places and worlds + * + * This script will: + * 1. Query all places/worlds with null SDK values + * 2. Fetch entity scenes from Catalyst (for places) or Worlds Content Server (for worlds) + * 3. Extract the runtimeVersion from scene.json + * 4. Update the SDK column in the database + * + * Usage by environment: + * npm run populate:sdk:dev [options] # Development + * npm run populate:sdk:stg [options] # Staging + * npm run populate:sdk:prd [options] # Production + * + * Options: + * --dry-run Preview changes without updating the database + * --limit N Limit the number of records to process (default: all) + * --places Only process places (Genesis City) + * --worlds Only process worlds + * + * Examples: + * npm run populate:sdk:dev -- --dry-run --limit 10 + * npm run populate:sdk:stg -- --dry-run + * npm run populate:sdk:prd -- --places + */ + +import logger from "decentraland-gatsby/dist/entities/Development/logger" +import Catalyst from "decentraland-gatsby/dist/utils/api/Catalyst" +import { ContentEntityScene } from "decentraland-gatsby/dist/utils/api/Catalyst.types" +import env from "decentraland-gatsby/dist/utils/env" +import fetch from "node-fetch" +import { Pool } from "pg" + +// Configuration +const BATCH_SIZE = 50 // Number of places to process in each batch +const DELAY_BETWEEN_BATCHES_MS = 1000 // Delay between batches to avoid rate limiting + +interface PlaceRecord { + id: string + base_position: string | null + world: boolean + world_name: string | null +} + +interface SceneJson { + runtimeVersion?: string +} + +/** + * Extract runtimeVersion from scene.json content + */ +async function fetchSceneJsonFromContent( + contentServerUrl: string, + contentEntityScene: ContentEntityScene +): Promise { + try { + const sceneJsonContent = contentEntityScene.content.find( + (content) => content.file === "scene.json" + ) + + if (!sceneJsonContent) { + return null + } + + const contentUrl = `${contentServerUrl.replace(/\/+$/, "")}/contents/${ + sceneJsonContent.hash + }` + const response = await fetch(contentUrl) + + if (!response.ok) { + return null + } + + const sceneJson: SceneJson = await response.json() + return sceneJson.runtimeVersion || null + } catch (error) { + return null + } +} + +/** + * Fetch runtimeVersion for a world from the Worlds Content Server + */ +async function fetchWorldRuntimeVersion( + worldsContentServerUrl: string, + worldName: string +): Promise { + try { + // First, get the world's entity info + const entityUrl = `${worldsContentServerUrl}/world/${worldName}/about` + const aboutResponse = await fetch(entityUrl) + + if (!aboutResponse.ok) { + return null + } + + const aboutData = (await aboutResponse.json()) as { + configurations?: { scenesUrn?: string[] } + } + const scenesUrn = aboutData?.configurations?.scenesUrn?.[0] + + if (!scenesUrn) { + return null + } + + // Extract entity ID from URN (format: urn:decentraland:entity:bafk...) + const entityIdMatch = scenesUrn.match(/urn:decentraland:entity:(.+)/) + if (!entityIdMatch) { + return null + } + + const entityId = entityIdMatch[1] + + // Fetch the entity content + const contentUrl = `${worldsContentServerUrl}/contents/${entityId}` + const contentResponse = await fetch(contentUrl) + + if (!contentResponse.ok) { + return null + } + + const entityData = (await contentResponse.json()) as ContentEntityScene & { + metadata?: { runtimeVersion?: string } + } + + // Use metadata.runtimeVersion if present to avoid fetching scene.json + if (entityData.metadata?.runtimeVersion) { + return entityData.metadata.runtimeVersion + } + + // Find scene.json in content + const sceneJsonContent = entityData.content?.find( + (content: { file: string }) => content.file === "scene.json" + ) + + if (!sceneJsonContent) { + return null + } + + // Fetch scene.json + const sceneJsonUrl = `${worldsContentServerUrl}/contents/${sceneJsonContent.hash}` + const sceneJsonResponse = await fetch(sceneJsonUrl) + + if (!sceneJsonResponse.ok) { + return null + } + + const sceneJson: SceneJson = await sceneJsonResponse.json() + return sceneJson.runtimeVersion || null + } catch (error) { + logger.error(`Error fetching world ${worldName}:`, error as any) + return null + } +} + +/** + * Process places in batches + */ +async function processPlaces( + pool: Pool, + places: PlaceRecord[], + dryRun: boolean +): Promise<{ updated: number; failed: number; skipped: number }> { + let updated = 0 + let failed = 0 + let skipped = 0 + + const catalyst = Catalyst.getInstance() + const catalystUrl = env("CATALYST_URL", "https://peer.decentraland.org") + const contentServerUrl = `${catalystUrl.replace(/\/+$/, "")}/content` + + // Process in batches + for (let i = 0; i < places.length; i += BATCH_SIZE) { + const batch = places.slice(i, i + BATCH_SIZE) + const pointers = batch.map((p) => p.base_position!).filter(Boolean) + + logger.log( + `Processing batch ${Math.floor(i / BATCH_SIZE) + 1}/${Math.ceil( + places.length / BATCH_SIZE + )} (${pointers.length} places)` + ) + + try { + // Fetch entity scenes for all pointers in batch + const entityScenes = await catalyst.getEntityScenes(pointers) + + // Create a map of pointer -> entity scene + const entityMap = new Map() + for (const entity of entityScenes) { + for (const pointer of entity.pointers) { + entityMap.set(pointer, entity) + } + } + + // Process each place in the batch + for (const place of batch) { + if (!place.base_position) { + skipped++ + continue + } + + const entity = entityMap.get(place.base_position) + if (!entity) { + logger.log(` No entity found for ${place.base_position}`) + skipped++ + continue + } + + const runtimeVersion = await fetchSceneJsonFromContent( + contentServerUrl, + entity + ) + + if (runtimeVersion) { + logger.log(` ${place.base_position}: SDK ${runtimeVersion}`) + + if (!dryRun) { + await pool.query("UPDATE places SET sdk = $1 WHERE id = $2", [ + runtimeVersion, + place.id, + ]) + } + updated++ + } else { + logger.log(` ${place.base_position}: No runtimeVersion found`) + failed++ + } + } + } catch (error) { + logger.error(`Error processing batch:`, error as any) + failed += batch.length + } + + // Delay between batches + if (i + BATCH_SIZE < places.length) { + await new Promise((resolve) => + setTimeout(resolve, DELAY_BETWEEN_BATCHES_MS) + ) + } + } + + return { updated, failed, skipped } +} + +/** + * Process worlds + */ +async function processWorlds( + pool: Pool, + worlds: PlaceRecord[], + dryRun: boolean, + worldsContentServerUrl: string +): Promise<{ updated: number; failed: number; skipped: number }> { + let updated = 0 + let failed = 0 + let skipped = 0 + + for (let i = 0; i < worlds.length; i++) { + const world = worlds[i] + + if (!world.world_name) { + skipped++ + continue + } + + logger.log( + `Processing world ${i + 1}/${worlds.length}: ${world.world_name}` + ) + + const runtimeVersion = await fetchWorldRuntimeVersion( + worldsContentServerUrl, + world.world_name + ) + + if (runtimeVersion) { + logger.log(` ${world.world_name}: SDK ${runtimeVersion}`) + + if (!dryRun) { + await pool.query("UPDATE places SET sdk = $1 WHERE id = $2", [ + runtimeVersion, + world.id, + ]) + } + updated++ + } else { + logger.log(` ${world.world_name}: No runtimeVersion found`) + failed++ + } + + // Small delay between requests + if (i < worlds.length - 1) { + await new Promise((resolve) => setTimeout(resolve, 100)) + } + } + + return { updated, failed, skipped } +} + +async function main() { + const args = process.argv.slice(2) + const dryRun = args.includes("--dry-run") + const onlyPlaces = args.includes("--places") + const onlyWorlds = args.includes("--worlds") + + const limitIndex = args.indexOf("--limit") + const limit = limitIndex !== -1 ? parseInt(args[limitIndex + 1], 10) : null + + // Determine environment from DOTENV_CONFIG_PATH + const envPath = process.env.DOTENV_CONFIG_PATH || ".env.development" + const envName = envPath.includes("prd") + ? "PRODUCTION" + : envPath.includes("stg") + ? "STAGING" + : "DEVELOPMENT" + + const catalystUrl = env("CATALYST_URL", "https://peer.decentraland.org") + const worldsContentServerUrl = env( + "WORLDS_CONTENT_SERVER_URL", + "https://worlds-content-server.decentraland.org" + ) + + logger.log("=".repeat(60)) + logger.log("SDK Population Script") + logger.log("=".repeat(60)) + logger.log(`Environment: ${envName}`) + logger.log(`Catalyst URL: ${catalystUrl}`) + logger.log(`Worlds Content Server: ${worldsContentServerUrl}`) + logger.log(`Mode: ${dryRun ? "DRY RUN (no changes will be made)" : "LIVE"}`) + logger.log(`Limit: ${limit || "No limit"}`) + logger.log( + `Processing: ${ + onlyPlaces + ? "Places only" + : onlyWorlds + ? "Worlds only" + : "Both places and worlds" + }` + ) + logger.log("=".repeat(60)) + + // Connect to database + const connectionString = process.env.CONNECTION_STRING + if (!connectionString) { + throw new Error("CONNECTION_STRING environment variable is required") + } + + const pool = new Pool({ connectionString }) + + try { + // Query places with null SDK + let placesQuery = ` + SELECT id, base_position, world, world_name + FROM places + WHERE sdk IS NULL AND disabled = false + ` + + if (onlyPlaces) { + placesQuery += " AND world = false" + } else if (onlyWorlds) { + placesQuery += " AND world = true" + } + + placesQuery += " ORDER BY created_at DESC" + + if (limit) { + placesQuery += ` LIMIT ${limit}` + } + + const result = await pool.query(placesQuery) + const records = result.rows + + logger.log(`Found ${records.length} records with null SDK`) + + const places = records.filter((r) => !r.world) + const worlds = records.filter((r) => r.world) + + logger.log(` - Places (Genesis City): ${places.length}`) + logger.log(` - Worlds: ${worlds.length}`) + logger.log("") + + let totalUpdated = 0 + let totalFailed = 0 + let totalSkipped = 0 + + // Process places + if (places.length > 0 && !onlyWorlds) { + logger.log("Processing Places...") + logger.log("-".repeat(40)) + const placesResult = await processPlaces(pool, places, dryRun) + totalUpdated += placesResult.updated + totalFailed += placesResult.failed + totalSkipped += placesResult.skipped + logger.log("") + } + + // Process worlds + if (worlds.length > 0 && !onlyPlaces) { + logger.log("Processing Worlds...") + logger.log("-".repeat(40)) + const worldsResult = await processWorlds( + pool, + worlds, + dryRun, + worldsContentServerUrl + ) + totalUpdated += worldsResult.updated + totalFailed += worldsResult.failed + totalSkipped += worldsResult.skipped + logger.log("") + } + + // Summary + logger.log("=".repeat(60)) + logger.log("Summary") + logger.log("=".repeat(60)) + logger.log(`Total processed: ${records.length}`) + logger.log(` - Updated: ${totalUpdated}`) + logger.log(` - Failed: ${totalFailed}`) + logger.log(` - Skipped: ${totalSkipped}`) + + if (dryRun) { + logger.log("") + logger.log("This was a dry run. No changes were made to the database.") + logger.log("Run without --dry-run to apply changes.") + } + } finally { + await pool.end() + } +} + +// Run the script +main().catch((error) => { + logger.error("Script failed:", error) + process.exit(1) +}) diff --git a/docs/ai-agent-context.md b/docs/ai-agent-context.md index 87bc2056..f521bd3e 100644 --- a/docs/ai-agent-context.md +++ b/docs/ai-agent-context.md @@ -62,7 +62,7 @@ The Decentraland Places service is a comprehensive API solution for discovering, - **User Visits**: Unique users who visited a place in the last 30 days - **Highlighted Places**: Featured places with special promotion status, managed via admin UI at `/admin/highlights/` - **Creator Address**: Ethereum address of the scene creator, extracted from scene metadata during deployment processing, indexed for efficient creator-based queries (used by tipping systems) -- **SDK Version**: Runtime version extracted from `runtimeVersion` in scene.json during deployment, used for filtering scenes by SDK version (e.g., SDK7 vs legacy) +- **SDK Version**: Runtime version extracted from `runtimeVersion` in scene.json during deployment, used for filtering scenes by SDK version. Supports major version matching (e.g., sdk=7 matches 7.x.x). Places with null SDK values are treated as SDK6 (legacy scenes) ## API Specification @@ -77,7 +77,7 @@ The service exposes a REST API under `/api` with comprehensive documentation in - **Reports**: `/api/report` (authentication required, returns S3 signed URL) - **Social**: `/places/place/`, `/places/world/` (metadata injection for social sharing) - **Creator Queries**: `/api/places?creator_address=0x...` (lookup places by scene creator for tipping integration) -- **SDK Filtering**: `/api/places?sdk=7` (filter by SDK version, exact match) +- **SDK Filtering**: `/api/places?sdk=7` (filter by SDK version, major version prefix match - sdk=7 matches 7, 7.0.0, 7.3.27, etc.) **Authentication**: Bearer token authentication using Decentraland wallet signatures. Admin endpoints require additional permissions. diff --git a/docs/openapi.yaml b/docs/openapi.yaml index c8fdca61..7429175d 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -255,10 +255,11 @@ paths: - name: sdk in: query description: | - Filter by SDK version. - Value is matched against the `runtimeVersion` from scene.json. - Places with null SDK values are also included. - Example: `7` for SDK7 scenes. + Filter by SDK version (major version matching). + Matches exact version or any version starting with the given prefix. + Example: `sdk=7` matches "7", "7.0.0", "7.3.27", etc. + Places with null SDK values are treated as SDK6 (legacy scenes). + Use `6` for SDK6/legacy scenes (including null), `7` for SDK7 scenes. required: false schema: type: string @@ -995,7 +996,7 @@ paths: - **Highlighted destinations are always returned first**, followed by **ranking value** (descending), then by the specified order_by - Filter places by exact coordinates (pointer) - Filter worlds by exact name (world_names) or LIKE matching (names) - - Filter by SDK version (includes places with null SDK) + - Filter by SDK version with prefix matching (e.g., sdk=7 matches 7.x.x; null values treated as SDK6) - Include connected user wallet addresses (with_connected_users) - Include live event status (with_live_events) @@ -1179,8 +1180,8 @@ paths: in: query description: | Filter by SDK version. - Places with null SDK values are also included. - Example: `7` for SDK7 scenes. + Places with null SDK values are treated as SDK6 (legacy scenes). + Example: `7` for SDK7 scenes, `6` for SDK6/legacy scenes. required: false schema: type: string @@ -1306,7 +1307,7 @@ paths: in: query description: | Filter by SDK version. - Places with null SDK values are also included. + Places with null SDK values are treated as SDK6 (legacy scenes). required: false schema: type: string diff --git a/src/entities/Destination/schemas.ts b/src/entities/Destination/schemas.ts index 3628f9ca..8c2fdcae 100644 --- a/src/entities/Destination/schemas.ts +++ b/src/entities/Destination/schemas.ts @@ -127,7 +127,8 @@ export const getDestinationsListQuerySchema = schema({ }, sdk: { type: "string", - description: "Filter by SDK version (includes places with null SDK)", + description: + "Filter by SDK version (major version matching). Use '6' to match 6.x.x and null/legacy scenes, '7' to match 7.x.x versions.", nullable: true as any, }, }, diff --git a/src/entities/Map/schemas.ts b/src/entities/Map/schemas.ts index 4ab8a1fa..5a4eaac5 100644 --- a/src/entities/Map/schemas.ts +++ b/src/entities/Map/schemas.ts @@ -169,7 +169,8 @@ export const getAllPlacesListQuerySchema = schema({ }, sdk: { type: "string", - description: 'Filter by SDK/runtime version (e.g., "7" for SDK7)', + description: + 'Filter by SDK version (major version matching). Use "6" to match 6.x.x and null/legacy scenes, "7" to match 7.x.x versions.', nullable: true as any, }, }, diff --git a/src/entities/Place/model.ts b/src/entities/Place/model.ts index becf37de..66b75335 100644 --- a/src/entities/Place/model.ts +++ b/src/entities/Place/model.ts @@ -134,7 +134,9 @@ export default class PlaceModel extends Model { )} ${conditional( !!options.sdk, - SQL` AND (${a}.sdk = ${options.sdk} OR ${a}.sdk IS NULL)` + SQL` AND (${a}.sdk = ${options.sdk} OR ${a}.sdk LIKE ${ + options.sdk + ".%" + }${options.sdk === "6" ? SQL` OR ${a}.sdk IS NULL` : SQL``})` )} ${conditional( !!options.ids?.length, @@ -887,7 +889,9 @@ export default class PlaceModel extends Model { )} ${conditional( !!options.sdk, - SQL` AND (p.sdk = ${options.sdk} OR p.sdk IS NULL)` + SQL` AND (p.sdk = ${options.sdk} OR p.sdk LIKE ${options.sdk + ".%"}${ + options.sdk === "6" ? SQL` OR p.sdk IS NULL` : SQL`` + })` )} ORDER BY ${conditional(!!options.search, SQL`rank DESC, `)} @@ -987,7 +991,9 @@ export default class PlaceModel extends Model { )} ${conditional( !!options.sdk, - SQL` AND (p.sdk = ${options.sdk} OR p.sdk IS NULL)` + SQL` AND (p.sdk = ${options.sdk} OR p.sdk LIKE ${options.sdk + ".%"}${ + options.sdk === "6" ? SQL` OR p.sdk IS NULL` : SQL`` + })` )} ` const results: { total: string }[] = await this.namedQuery( diff --git a/src/entities/Place/schemas.ts b/src/entities/Place/schemas.ts index ae1306cd..e77db3cd 100644 --- a/src/entities/Place/schemas.ts +++ b/src/entities/Place/schemas.ts @@ -93,7 +93,8 @@ export const getPlaceListQuerySchema = schema({ }, sdk: { type: "string", - description: "Filter places by SDK version (e.g., '7' for SDK7 scenes)", + description: + "Filter places by SDK version (major version matching). Use '6' to match 6.x.x and null/legacy scenes, '7' to match 7.x.x versions.", nullable: true as any, }, names: { diff --git a/test/integration/getPlacesList.test.ts b/test/integration/getPlacesList.test.ts new file mode 100644 index 00000000..66454e65 --- /dev/null +++ b/test/integration/getPlacesList.test.ts @@ -0,0 +1,221 @@ +import { randomUUID } from "crypto" + +import database from "decentraland-gatsby/dist/entities/Database/database" +import { SceneContentRating } from "decentraland-gatsby/dist/utils/api/Catalyst.types" +import supertest from "supertest" + +import PlaceModel from "../../src/entities/Place/model" +import { PlaceAttributes } from "../../src/entities/Place/types" +import * as hotScenesModule from "../../src/modules/hotScenes" +import { cleanTables, closeTestDb, initTestDb } from "../setup/db" +import { createTestApp } from "../setup/server" + +jest.mock( + "decentraland-gatsby/dist/entities/Auth/routes/withDecentralandAuth", + () => { + const userAddress = "0x1234567890123456789012345678901234567890" + const mockWithAuth = jest.fn().mockResolvedValue({ + address: userAddress, + metadata: {}, + }) + return { + __esModule: true, + default: jest.fn(() => mockWithAuth), + withAuth: mockWithAuth, + withAuthOptional: jest.fn().mockResolvedValue({ + address: userAddress, + metadata: {}, + }), + } + } +) + +jest.mock("../../src/entities/Snapshot/utils", () => ({ + fetchScore: jest.fn().mockResolvedValue(150), +})) + +jest.mock("../../src/entities/Slack/utils", () => ({ + notifyDowngradeRating: jest.fn(), + notifyUpgradingRating: jest.fn(), + notifyError: jest.fn(), + notifyNewPlace: jest.fn(), + notifyUpdatePlace: jest.fn(), + notifyDisablePlaces: jest.fn(), +})) + +jest.mock("../../src/modules/hotScenes", () => ({ + getHotScenes: jest.fn().mockReturnValue([]), +})) +jest.mock("../../src/modules/sceneStats", () => ({ + getSceneStats: jest.fn().mockResolvedValue({}), +})) +jest.mock("../../src/modules/worldsLiveData", () => ({ + getWorldsLiveData: jest.fn().mockResolvedValue({ + perWorld: [], + totalUsers: 0, + }), +})) + +jest.mock("../../src/api/CatalystAPI", () => ({ + __esModule: true, + default: { + get: jest.fn().mockReturnValue({ + getAllOperatedLands: jest.fn().mockResolvedValue([]), + }), + }, +})) + +const app = createTestApp() + +function createPlaceAttributes( + overrides: Partial = {} +): PlaceAttributes { + return { + id: randomUUID(), + title: "Test Place", + description: "A test place", + image: "https://example.com/image.png", + owner: null, + positions: ["0,0"], + base_position: "0,0", + contact_name: null, + contact_email: null, + content_rating: SceneContentRating.RATING_PENDING, + categories: [], + likes: 0, + dislikes: 0, + favorites: 0, + like_rate: null, + like_score: null, + disabled: false, + disabled_at: null, + created_at: new Date(), + updated_at: new Date(), + highlighted: false, + highlighted_image: null, + world: false, + world_name: null, + world_id: null, + deployed_at: new Date(), + textsearch: null, + creator_address: null, + sdk: null, + ranking: 0, + ...overrides, + } +} + +async function seedPlace( + overrides: Partial = {} +): Promise { + const place = createPlaceAttributes(overrides) + await PlaceModel.create(place) + + if (place.title || place.description || place.owner) { + await database.query( + `UPDATE places SET textsearch = ( + setweight(to_tsvector(coalesce($1, '')), 'A') || + setweight(to_tsvector(coalesce($2, '')), 'B') || + setweight(to_tsvector(coalesce($3, '')), 'C') + ) WHERE id = $4`, + [place.title, place.description, place.owner, place.id] as string[] + ) + } + + return place +} + +describe("when fetching places via GET /api/places", () => { + beforeAll(async () => { + await initTestDb() + }) + + afterAll(async () => { + await closeTestDb() + }) + + afterEach(async () => { + await cleanTables() + jest.clearAllMocks() + }) + + describe("and no places exist", () => { + it("should respond with an empty list and total 0", async () => { + const response = await supertest(app).get("/api/places").expect(200) + + expect(response.body.ok).toBe(true) + expect(response.body.data).toEqual([]) + expect(response.body.total).toBe(0) + }) + }) + + describe("and the sdk filter is applied", () => { + let placeSdk6: PlaceAttributes + let placeSdk6Patch: PlaceAttributes + let placeSdk7: PlaceAttributes + let placeSdkNull: PlaceAttributes + + beforeEach(async () => { + placeSdk6 = await seedPlace({ + title: "Place SDK 6", + base_position: "0,0", + positions: ["0,0"], + sdk: "6", + deployed_at: new Date("2024-01-01"), + }) + placeSdk6Patch = await seedPlace({ + title: "Place SDK 6.5", + base_position: "1,1", + positions: ["1,1"], + sdk: "6.5.0", + deployed_at: new Date("2024-02-01"), + }) + placeSdk7 = await seedPlace({ + title: "Place SDK 7", + base_position: "2,2", + positions: ["2,2"], + sdk: "7", + deployed_at: new Date("2025-01-01"), + }) + placeSdkNull = await seedPlace({ + title: "Legacy Place No SDK", + base_position: "3,3", + positions: ["3,3"], + sdk: null, + deployed_at: new Date("2023-01-01"), + }) + }) + + describe("with sdk=6", () => { + it("should return places with SDK 6, 6.x (prefix match), and null SDK (legacy scenes)", async () => { + const response = await supertest(app) + .get("/api/places") + .query({ sdk: "6" }) + .expect(200) + + expect(response.body.ok).toBe(true) + expect(response.body.total).toBe(3) + + const ids = response.body.data.map((d: { id: string }) => d.id) + expect(ids).toContain(placeSdk6.id) + expect(ids).toContain(placeSdk6Patch.id) + expect(ids).toContain(placeSdkNull.id) + expect(ids).not.toContain(placeSdk7.id) + }) + }) + + describe("with sdk=7", () => { + it("should return only places with SDK 7 or 7.x, and not include null SDK", async () => { + const response = await supertest(app) + .get("/api/places") + .query({ sdk: "7" }) + .expect(200) + + expect(response.body.ok).toBe(true) + expect(response.body.total).toBe(1) + expect(response.body.data[0].id).toBe(placeSdk7.id) + expect(response.body.data[0].sdk).toBe("7") + }) + }) + }) +})