Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(advertisement): adds getRandom function to retrieve random ads #1745

Merged
merged 1 commit into from
Jul 14, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
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
30 changes: 30 additions & 0 deletions models/advertisement.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import database from 'infra/database';

async function getRandom(limit) {
const query = {
values: [limit],
};

query.text = `
SELECT
c.id,
c.slug,
c.title,
c.source_url,
u.username as owner_username,
'markdown' as ad_type
FROM contents c
INNER JOIN users u ON c.owner_id = u.id
WHERE type = 'ad' AND status = 'published'
ORDER BY RANDOM()
LIMIT $1;
`;

const results = await database.query(query);

return results.rows;
}

export default Object.freeze({
getRandom,
});
14 changes: 14 additions & 0 deletions models/authorization.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ const availableFeatures = new Set([
'update:user:others',
'ban:user',
'create:recovery_token:username',

// ADVERTISEMENT
'read:ad:list',
]);

function can(user, feature, resource) {
Expand Down Expand Up @@ -290,6 +293,17 @@ function filterOutput(user, feature, output) {
});
}

if (feature === 'read:ad:list') {
filteredOutputValues = validator(
{
ad_list: output,
},
{
ad_list: 'required',
},
).ad_list;
}

// Force the clean up of "undefined" values
return JSON.parse(JSON.stringify(filteredOutputValues));
}
Expand Down
48 changes: 48 additions & 0 deletions models/validator.js
Original file line number Diff line number Diff line change
Expand Up @@ -511,6 +511,54 @@ const schemas = {
return contentSchema;
},

ad: function () {
let contentSchema = Joi.object({
children: Joi.array().optional().items(Joi.link('#content')),
})
.required()
.min(1)
.id('ad');
Rafatcb marked this conversation as resolved.
Show resolved Hide resolved

for (const key of [
'id',
'owner_id',
'slug',
'title',
'body',
'status',
'ad_type',
'source_url',
'created_at',
'updated_at',
'published_at',
'deleted_at',
'owner_username',
'children_deep_count',
'tabcash',
]) {
const keyValidationFunction = schemas[key];
contentSchema = contentSchema.concat(keyValidationFunction());
}

return contentSchema;
},

ad_list: function () {
return Joi.object({
ad_list: Joi.array().items(Joi.link('#ad')).required().shared(schemas.ad()),
});
},

ad_type: function () {
return Joi.object({
type: Joi.string()
.trim()
.valid('markdown')
.default('markdown')
.when('$required.ad_type', { is: 'required', then: Joi.required(), otherwise: Joi.optional() }),
});
},

with_children: function () {
return Joi.object({
with_children: Joi.boolean().when('$required.with_children', {
Expand Down
37 changes: 37 additions & 0 deletions pages/api/v1/sponsored-beta/index.public.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import nextConnect from 'next-connect';

import ad from 'models/advertisement';
import authorization from 'models/authorization.js';
import cacheControl from 'models/cache-control';
import controller from 'models/controller.js';
import user from 'models/user.js';
import validator from 'models/validator.js';

export default nextConnect({
attachParams: true,
onNoMatch: controller.onNoMatchHandler,
onError: controller.onErrorHandler,
})
.use(controller.injectRequestMetadata)
.use(controller.logRequest)
.get(cacheControl.swrMaxAge(10), getValidationHandler, getHandler);

function getValidationHandler(request, response, next) {
const cleanValues = validator(request.query, {
per_page: 'optional',
});

request.query = cleanValues;

next();
}

async function getHandler(request, response) {
const userTryingToList = user.createAnonymous();

const ads = await ad.getRandom(request.query.per_page);

const secureOutputValues = authorization.filterOutput(userTryingToList, 'read:ad:list', ads);

return response.status(200).json(secureOutputValues);
}
111 changes: 111 additions & 0 deletions tests/integration/api/v1/sponsored-beta/get.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { defaultTabCashForAdCreation } from 'tests/constants-for-tests';
import orchestrator from 'tests/orchestrator.js';
import RequestBuilder from 'tests/request-builder';

beforeAll(async () => {
await orchestrator.waitForAllServices();
});

describe('GET /api/v1/sponsored-beta', () => {
describe('Anonymous user', () => {
const adsRequestBuilder = new RequestBuilder('/api/v1/sponsored-beta');
let owner;

beforeEach(async () => {
await orchestrator.dropAllTables();
await orchestrator.runPendingMigrations();
owner = await orchestrator.createUser();

orchestrator.createBalance({
balanceType: 'user:tabcash',
recipientId: owner.id,
amount: 100 * defaultTabCashForAdCreation,
});
});

it('should never get default content', async () => {
await orchestrator.createContent({
owner_id: owner.id,
title: 'Content',
status: 'published',
type: 'content',
});

const { response, responseBody } = await adsRequestBuilder.get();

expect.soft(response.status).toBe(200);
expect(responseBody).toEqual([]);
});

it('should never get unpublished ad', async () => {
await orchestrator.createContent({
owner_id: owner.id,
title: 'Draft Ad',
status: 'draft',
type: 'ad',
});

const deletedAd = await orchestrator.createContent({
owner_id: owner.id,
title: 'Deleted Ad',
status: 'published',
type: 'ad',
});

await orchestrator.updateContent(deletedAd.id, { status: 'deleted' });

const { response, responseBody } = await adsRequestBuilder.get();

expect(response.status).toBe(200);
expect(responseBody).toEqual([]);
});

it('should return ads', async () => {
const createdAds = await createAds(4, owner);

const { response, responseBody } = await adsRequestBuilder.get();

expect.soft(response.status).toBe(200);

expect(createdAds).toContainEqual(responseBody[0]);
expect(createdAds).toContainEqual(responseBody[1]);
expect(createdAds).toContainEqual(responseBody[2]);
expect(createdAds).toContainEqual(responseBody[3]);
});

it('should limit the number of ads returned', async () => {
const createdAds = await createAds(3, owner);

const { response, responseBody } = await adsRequestBuilder.get('?per_page=2');

expect.soft(response.status).toBe(200);
expect.soft(responseBody).toHaveLength(2);

expect(createdAds).toContainEqual(responseBody[0]);
expect(createdAds).toContainEqual(responseBody[1]);
});
});
});

async function createAds(count, owner) {
const ads = [];
for (let i = 0; i < count; i++) {
const ad = await orchestrator.createContent({
owner_id: owner.id,
title: `Ad #${i}`,
status: 'published',
type: 'ad',
});

ads.push({
id: ad.id,
title: ad.title,
slug: ad.slug,
owner_username: owner.username,
source_url: ad.source_url,
type: 'markdown',
});
}

return ads;
}
Loading