Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
155 changes: 155 additions & 0 deletions packages/stack/src/plugins/media/__tests__/getters.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
getAssetById,
listFolders,
getFolderById,
getFolderByName,
} from "../api/getters";

const createTestAdapter = (): Adapter => {
Expand Down Expand Up @@ -162,6 +163,30 @@ describe("media getters", () => {
expect(photoResult.items[0]!.filename).toBe("holiday-photo.jpg");
});

it("filters assets by tenantId", async () => {
await adapter.create({
model: "mediaAsset",
data: makeAsset({
filename: "tenant-a.jpg",
url: "https://example.com/a.jpg",
}),
});
const assetB = await adapter.create<{ id: string }>({
model: "mediaAsset",
data: {
...makeAsset({
filename: "tenant-b.jpg",
url: "https://example.com/b.jpg",
}),
tenantId: "tenant-b",
},
});

const result = await listAssets(adapter, { tenantId: "tenant-b" });
expect(result.items).toHaveLength(1);
expect(result.items[0]!.id).toBe(assetB.id);
});

it("paginates results with limit and offset", async () => {
for (let i = 0; i < 5; i++) {
await adapter.create({
Expand Down Expand Up @@ -231,6 +256,24 @@ describe("media getters", () => {
expect(result[1]!.name).toBe("Zeta");
});

it("filters folders by tenantId", async () => {
await adapter.create({
model: "mediaFolder",
data: makeFolder({ name: "No Tenant" }),
});
const tenantFolder = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: {
...makeFolder({ name: "Tenant Folder" }),
tenantId: "tenant-x",
},
});

const result = await listFolders(adapter, { tenantId: "tenant-x" });
expect(result).toHaveLength(1);
expect(result[0]!.id).toBe(tenantFolder.id);
});

it("filters folders by parentId", async () => {
const root = await adapter.create<{ id: string }>({
model: "mediaFolder",
Expand Down Expand Up @@ -271,4 +314,116 @@ describe("media getters", () => {
expect(result!.name).toBe("Test Folder");
});
});

// ── getFolderByName ───────────────────────────────────────────────────────

describe("getFolderByName", () => {
it("returns null when no folder exists", async () => {
const result = await getFolderByName(adapter, "nonexistent");
expect(result).toBeNull();
});

it("finds a root-level folder by name with no parentId constraint", async () => {
await adapter.create({
model: "mediaFolder",
data: makeFolder({ name: "blog-gen-profile-1" }),
});

const result = await getFolderByName(adapter, "blog-gen-profile-1");
expect(result).not.toBeNull();
expect(result!.name).toBe("blog-gen-profile-1");
});

it("returns null when name matches but parentId does not", async () => {
const parent = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: makeFolder({ name: "Parent" }),
});
await adapter.create({
model: "mediaFolder",
data: makeFolder({ name: "Child", parentId: parent.id }),
});

// Search for "Child" scoped to a different (non-existent) parent.
const result = await getFolderByName(adapter, "Child", "wrong-parent-id");
expect(result).toBeNull();
});

it("scopes lookup to root-level folders when parentId is null", async () => {
// Explicitly store parentId as null (mirrors how SQL adapters persist root folders).
const rootFolder = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: { ...makeFolder({ name: "Images" }), parentId: null },
});
// A nested folder with the same name.
await adapter.create({
model: "mediaFolder",
data: makeFolder({ name: "Images", parentId: rootFolder.id }),
});

// Only the root-level "Images" (parentId = null) should be returned.
const result = await getFolderByName(adapter, "Images", null);
expect(result).not.toBeNull();
expect(result!.id).toBe(rootFolder.id);
});

it("finds a child folder scoped to the correct parentId", async () => {
const parentA = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: makeFolder({ name: "Parent A" }),
});
const parentB = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: makeFolder({ name: "Parent B" }),
});
const childOfA = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: makeFolder({ name: "Child", parentId: parentA.id }),
});
await adapter.create({
model: "mediaFolder",
data: makeFolder({ name: "Child", parentId: parentB.id }),
});

const result = await getFolderByName(adapter, "Child", parentA.id);
expect(result).not.toBeNull();
expect(result!.id).toBe(childOfA.id);
});

it("filters by tenantId when provided", async () => {
await adapter.create({
model: "mediaFolder",
data: { ...makeFolder({ name: "blog-gen-abc" }), tenantId: "tenant-1" },
});
const folderT2 = await adapter.create<{ id: string }>({
model: "mediaFolder",
data: { ...makeFolder({ name: "blog-gen-abc" }), tenantId: "tenant-2" },
});

// Same name, different tenant — should only return the matching one.
const result = await getFolderByName(
adapter,
"blog-gen-abc",
undefined,
"tenant-2",
);
expect(result).not.toBeNull();
expect(result!.id).toBe(folderT2.id);
});

it("returns null when tenantId does not match", async () => {
await adapter.create({
model: "mediaFolder",
data: { ...makeFolder({ name: "blog-gen-xyz" }), tenantId: "tenant-1" },
});

const result = await getFolderByName(
adapter,
"blog-gen-xyz",
undefined,
"tenant-999",
);
expect(result).toBeNull();
});
});
});
30 changes: 30 additions & 0 deletions packages/stack/src/plugins/media/__tests__/mutations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,21 @@ describe("media mutations", () => {
expect(asset.alt).toBe("A beautiful photo");
});

it("persists tenantId when provided", async () => {
const asset = await createAsset(adapter, {
...assetInput,
tenantId: "profile-abc",
});

expect(asset.tenantId).toBe("profile-abc");
});

it("leaves tenantId absent when not provided", async () => {
const asset = await createAsset(adapter, assetInput);

expect(asset.tenantId == null).toBe(true);
});

it("creates multiple independent assets", async () => {
await createAsset(adapter, {
...assetInput,
Expand Down Expand Up @@ -194,6 +209,21 @@ describe("media mutations", () => {
expect(folder.createdAt).toBeInstanceOf(Date);
});

it("persists tenantId when provided", async () => {
const folder = await createFolder(adapter, {
name: "Tenant Folder",
tenantId: "profile-xyz",
});

expect(folder.tenantId).toBe("profile-xyz");
});

it("leaves tenantId absent when not provided", async () => {
const folder = await createFolder(adapter, { name: "No Tenant" });

expect(folder.tenantId == null).toBe(true);
});

it("creates a nested folder with parentId", async () => {
const parent = await createFolder(adapter, { name: "Root" });
const child = await createFolder(adapter, {
Expand Down
77 changes: 64 additions & 13 deletions packages/stack/src/plugins/media/api/getters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export interface AssetListParams {
query?: string;
offset?: number;
limit?: number;
tenantId?: string;
}

/**
Expand All @@ -27,6 +28,7 @@ export interface AssetListResult {
*/
export interface FolderListParams {
parentId?: string | null;
tenantId?: string;
}

/**
Expand Down Expand Up @@ -64,6 +66,14 @@ export async function listAssets(
});
}

if (query.tenantId !== undefined) {
whereConditions.push({
field: "tenantId",
value: query.tenantId,
operator: "eq" as const,
});
}

const needsInMemoryFilter = !!query.query;
const dbWhere = whereConditions.length > 0 ? whereConditions : undefined;

Expand Down Expand Up @@ -136,22 +146,31 @@ export async function listFolders(
adapter: Adapter,
params?: FolderListParams,
): Promise<Folder[]> {
// Only add a where clause when parentId is explicitly provided as a string.
// Passing undefined (or no params) returns all folders unfiltered.
const where =
params?.parentId !== undefined
? [
{
field: "parentId",
value: params.parentId,
operator: "eq" as const,
},
]
: undefined;
const whereConditions: Array<{
field: string;
value: string | null;
operator: "eq";
}> = [];

if (params?.parentId !== undefined) {
whereConditions.push({
field: "parentId",
value: params.parentId,
operator: "eq" as const,
});
}

if (params?.tenantId !== undefined) {
whereConditions.push({
field: "tenantId",
value: params.tenantId,
operator: "eq" as const,
});
}

return adapter.findMany<Folder>({
model: "mediaFolder",
where,
where: whereConditions.length > 0 ? whereConditions : undefined,
sortBy: { field: "name", direction: "asc" },
});
}
Expand All @@ -172,3 +191,35 @@ export async function getFolderById(
where: [{ field: "id", value: id, operator: "eq" as const }],
});
}

/**
* Find a single folder by name, optionally scoped to a specific parent and/or tenant.
* Pass `null` for `parentId` to search only root-level folders.
* Pass `undefined` for `parentId` to search regardless of parent.
* Returns `null` if no matching folder is found.
*
* Pure DB function — no hooks, no HTTP context.
* The caller is responsible for any access-control checks.
*/
export async function getFolderByName(
adapter: Adapter,
name: string,
parentId?: string | null,
tenantId?: string,
): Promise<Folder | null> {
const where: Array<{
field: string;
value: string | null;
operator: "eq";
}> = [{ field: "name", value: name, operator: "eq" as const }];

if (parentId !== undefined) {
where.push({ field: "parentId", value: parentId, operator: "eq" as const });
}

if (tenantId !== undefined) {
where.push({ field: "tenantId", value: tenantId, operator: "eq" as const });
}

return adapter.findOne<Folder>({ model: "mediaFolder", where });
}
1 change: 1 addition & 0 deletions packages/stack/src/plugins/media/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export {
getAssetById,
listFolders,
getFolderById,
getFolderByName,
type AssetListParams,
type AssetListResult,
type FolderListParams,
Expand Down
4 changes: 4 additions & 0 deletions packages/stack/src/plugins/media/api/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export interface CreateAssetInput {
url: string;
folderId?: string;
alt?: string;
tenantId?: string;
}

/**
Expand All @@ -28,6 +29,7 @@ export interface UpdateAssetInput {
export interface CreateFolderInput {
name: string;
parentId?: string;
tenantId?: string;
}

/**
Expand All @@ -51,6 +53,7 @@ export async function createAsset(
url: input.url,
folderId: input.folderId,
alt: input.alt,
tenantId: input.tenantId,
createdAt: new Date(),
},
});
Expand Down Expand Up @@ -114,6 +117,7 @@ export async function createFolder(
data: {
name: input.name,
parentId: input.parentId,
tenantId: input.tenantId,
createdAt: new Date(),
},
});
Expand Down
Loading
Loading