diff --git a/src/__tests__/areas.ts b/src/__tests__/areas.ts index aea41608..009b48b6 100644 --- a/src/__tests__/areas.ts +++ b/src/__tests__/areas.ts @@ -98,6 +98,27 @@ describe('areas API', () => { }) }) + it('retrieves an area and its cumulative media weight', async () => { + const response = await queryAPI({ + query: ` + query area($input: ID) { + area(uuid: $input) { + uuid + imageByteSum + } + } + `, + operationName: 'area', + variables: { input: ca.metadata.area_id }, + userUuid, + app + }) + expect(response.statusCode).toBe(200) + const areaResult = response.body.data.area + expect(areaResult.uuid).toBe(muuidToString(ca.metadata.area_id)) + expect(areaResult.imageByteSum).toBe(0) + }) + it('retrieves an area omitting organizations that exclude it', async () => { const response = await queryAPI({ query: areaQuery, diff --git a/src/graphql/resolvers.ts b/src/graphql/resolvers.ts index b173a03d..bca1890e 100644 --- a/src/graphql/resolvers.ts +++ b/src/graphql/resolvers.ts @@ -221,9 +221,10 @@ const resolvers = { return [] }, - aggregate: async (node: AreaType) => { - return node.aggregate - }, + aggregate: async (node: AreaType) => node.aggregate, + + imageByteSum: async (node: AreaType, _, { dataSources: { areas } }: GQLContext) => + await areas.computeImageByteSum(node.metadata.area_id), ancestors: async (parent) => parent.ancestors?.split(',') ?? [], diff --git a/src/graphql/schema/Area.gql b/src/graphql/schema/Area.gql index cd717eff..126ad571 100644 --- a/src/graphql/schema/Area.gql +++ b/src/graphql/schema/Area.gql @@ -79,6 +79,15 @@ type Area { authorMetadata: AuthorMetadata! "Organizations associated with this area or its parent areas" organizations: [Organization] + + """ + If you were to sum all approximate image sizes for this area, you get + a kind of picture of what the cost to cache all of the media in a given + area might be. You could use this to indicate a recommended compression + ratio or maybe know ahead of time if a given cache exercise would exceed + current storage capacity. + """ + imageByteSum: Int! } """ diff --git a/src/model/AreaDataSource.ts b/src/model/AreaDataSource.ts index a027f3f6..1c27d5ec 100644 --- a/src/model/AreaDataSource.ts +++ b/src/model/AreaDataSource.ts @@ -133,6 +133,30 @@ export default class AreaDataSource extends MongoDataSource { return rs } + async computeImageByteSum (uuid: muuid.MUUID): Promise { + const descendantUuids = await this.areaModel.find({ + ancestors: { $regex: new RegExp(`(?:^|,)${uuid.toString()}(?:,|$)`) } + }, { 'metadata.area_id': 1, climbs: 1 }) + + return await this.mediaObjectModal.aggregate([ + { + $match: { + 'entityTags.targetId': { + $in: [...descendantUuids.map(i => i.metadata.area_id), + ...descendantUuids.reduce((prev, curr) => [...prev, ...curr.climbs], []) + ] + } + } + }, + { + $group: { + _id: null, + totalSize: { $sum: '$size' } + } + } + ]).then(d => d[0]?.totalSize) ?? 0 + } + /** * Find a climb by uuid. Also return the parent area object (crag or boulder). * diff --git a/src/model/__tests__/AreaDataSource.test.ts b/src/model/__tests__/AreaDataSource.test.ts new file mode 100644 index 00000000..2afe3d62 --- /dev/null +++ b/src/model/__tests__/AreaDataSource.test.ts @@ -0,0 +1,124 @@ +import { getAreaModel, createIndexes } from "../../db" +import inMemoryDB from "../../utils/inMemoryDB" +import MutableAreaDataSource from "../MutableAreaDataSource" +import muid, { MUUID } from 'uuid-mongodb' +import { AreaType } from "../../db/AreaTypes" +import AreaDataSource from "../AreaDataSource" +import MutableMediaDataSource from "../MutableMediaDataSource" +import { MediaObjectGQLInput } from "../../db/MediaObjectTypes" +import MutableClimbDataSource from "../MutableClimbDataSource" +import muuid from 'uuid-mongodb' + + +function mediaInput(val: number) { + return { + userUuid: 'a2eb6353-65d1-445f-912c-53c6301404bd', + mediaUrl: `/u/a2eb6353-65d1-445f-912c-53c6301404bd/photo${val}.jpg`, + width: 800, + height: 600, + format: 'jpeg', + size: 45000 + Math.floor(Math.random() * 100) +} satisfies MediaObjectGQLInput} + +describe("Test area data source", () => { + let areas: AreaDataSource + let rootCountry: AreaType + let areaCounter = 0 + const testUser = muid.v4() + + async function addArea(name?: string, extra?: Partial<{ leaf: boolean, boulder: boolean, parent: MUUID | AreaType}>) { + function isArea(x: any): x is AreaType { + return typeof x.metadata?.area_id !== 'undefined' + } + + areaCounter += 1 + if (name === undefined || name === 'test') { + name = process.uptime().toString() + '-' + areaCounter.toString() + } + + let parent: MUUID | undefined = undefined + if (extra?.parent) { + if (isArea(extra.parent)) { + parent = extra.parent.metadata?.area_id + } else { + parent = extra.parent + } + } + + return MutableAreaDataSource.getInstance().addArea( + testUser, + name, + parent ?? rootCountry.metadata.area_id, + undefined, + undefined, + extra?.leaf, + extra?.boulder + ) + } + + beforeAll(async () => { + await inMemoryDB.connect() + await getAreaModel().collection.drop() + await createIndexes() + areas = MutableAreaDataSource.getInstance() + // We need a root country, and it is beyond the scope of these tests + rootCountry = await MutableAreaDataSource.getInstance().addCountry("USA") + }) + + afterAll(inMemoryDB.close) + + describe("Image size summing", () => { + test("Area image size summing should not produce false counts", async () => { + const area = await addArea() + const val = await areas.computeImageByteSum(area.metadata.area_id) + expect(val).toBe(0) + }) + + test("Area image size summing should work for direct tags", async () => { + const area = await addArea() + const media = MutableMediaDataSource.getInstance() + const [object] = await media.addMediaObjects([mediaInput(0)]) + media.upsertEntityTag({ entityType: 1, entityUuid: area.metadata.area_id, mediaId: object._id }) + const val = await areas.computeImageByteSum(area.metadata.area_id) + expect(val).toBe(object.size) + }) + + test("Area image size summing should work for direct tags to children", async () => { + const media = MutableMediaDataSource.getInstance() + const area = await addArea() + let child = area + let sizeAccumulator = 0 + for (const idx of Array.from({ length: 10}).map((_, idx) => idx)) { + child = await addArea(undefined, { parent: child.metadata.area_id}) + const [object] = await media.addMediaObjects([mediaInput((idx + 1) * 10)]) + media.upsertEntityTag({ entityType: 1, entityUuid: child.metadata.area_id, mediaId: object._id }) + + // We always query the top level + expect(await areas.computeImageByteSum(area.metadata.area_id).then(d => { + sizeAccumulator += d + return sizeAccumulator + })).toBe(sizeAccumulator) + // equally, we expect the child to not get reverse-polluted + expect(await areas.computeImageByteSum(child.metadata.area_id)).toBe(object.size) + } + }) + + test("Area image size summing should work for direct tags to climbs", async () => { + const area = await addArea() + const media = MutableMediaDataSource.getInstance() + const climbs = MutableClimbDataSource.getInstance() + const child = await addArea(undefined, { parent: area.metadata.area_id}) + expect(await areas.computeImageByteSum(area.metadata.area_id)).toBe(0) + expect(await areas.computeImageByteSum(child.metadata.area_id)).toBe(0) + + const [object] = await media.addMediaObjects([mediaInput(2 * 100)]) + const [climb] = await climbs.addOrUpdateClimbs(object.userUuid, child.metadata.area_id, [{ + name: "climb", + grade: "6c+" + }]) + + media.upsertEntityTag({ entityType: 0, entityUuid: muuid.from(climb), mediaId: object._id }) + expect(await areas.computeImageByteSum(area.metadata.area_id)).toBe(object.size) + }) + }) +}) \ No newline at end of file