diff --git a/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js b/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js new file mode 100644 index 0000000000..bd05dd0291 --- /dev/null +++ b/packages/api/db/migration/20251024195921_add_market_directory_info_permissions.js @@ -0,0 +1,70 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ + +export const up = async function (knex) { + await knex('permissions').insert([ + { + permission_id: 179, + name: 'add:market_directory_info', + description: 'add market_directory_info', + }, + { + permission_id: 180, + name: 'edit:market_directory_info', + description: 'edit market_directory_info', + }, + { + permission_id: 181, + name: 'delete:market_directory_info', + description: 'delete market_directory_info', + }, + { + permission_id: 182, + name: 'get:market_directory_info', + description: 'get market_directory_info', + }, + ]); + await knex('rolePermissions').insert([ + { role_id: 1, permission_id: 179 }, + { role_id: 2, permission_id: 179 }, + { role_id: 5, permission_id: 179 }, + { role_id: 1, permission_id: 180 }, + { role_id: 2, permission_id: 180 }, + { role_id: 5, permission_id: 180 }, + { role_id: 1, permission_id: 181 }, + { role_id: 2, permission_id: 181 }, + { role_id: 5, permission_id: 181 }, + { role_id: 1, permission_id: 182 }, + { role_id: 2, permission_id: 182 }, + { role_id: 5, permission_id: 182 }, + ]); +}; + +/** + * @param { import("knex").Knex } knex + * @returns { Promise } + */ +export const down = function (knex) { + const permissions = [179, 180, 181, 182]; + return Promise.all([ + knex('rolePermissions').whereIn('permission_id', permissions).del(), + knex('permissions').whereIn('permission_id', permissions).del(), + ]); +}; diff --git a/packages/api/src/controllers/marketDirectoryInfoController.ts b/packages/api/src/controllers/marketDirectoryInfoController.ts new file mode 100644 index 0000000000..1867333886 --- /dev/null +++ b/packages/api/src/controllers/marketDirectoryInfoController.ts @@ -0,0 +1,50 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { Response } from 'express'; +import baseController from './baseController.js'; +import MarketDirectoryInfoModel from '../models/marketDirectoryInfoModel.js'; +import { MarketDirectoryInfoReqBody } from '../middleware/validation/checkMarketDirectoryInfo.js'; +import { HttpError, LiteFarmRequest } from '../types.js'; + +const marketDirectoryInfoController = { + addMarketDirectoryInfo() { + return async ( + req: LiteFarmRequest, + res: Response, + ) => { + const { farm_id } = req.headers; + + try { + const result = await baseController.post( + MarketDirectoryInfoModel, + { ...req.body, farm_id }, + req, + ); + + return res.status(201).send(result); + } catch (error: unknown) { + console.error(error); + const err = error as HttpError; + const status = err.status || err.code || 500; + return res.status(status).json({ + error: err.message || err, + }); + } + }; + }, +}; + +export default marketDirectoryInfoController; diff --git a/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts new file mode 100644 index 0000000000..8b0d4456c3 --- /dev/null +++ b/packages/api/src/middleware/validation/checkMarketDirectoryInfo.ts @@ -0,0 +1,84 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { Response, NextFunction } from 'express'; +import { LiteFarmRequest } from '../../types.js'; +import { isValidAddress, isValidEmail } from '../../util/validation.js'; +import { isValidUrl } from '../../util/url.js'; +import { SOCIALS, validateSocialAndExtractUsername } from '../../util/socials.js'; +import MarketDirectoryInfoModel from '../../models/marketDirectoryInfoModel.js'; + +export interface MarketDirectoryInfoReqBody { + farm_name?: string; + logo?: string; + about?: string; + contact_first_name?: string; + contact_last_name?: string; + contact_email?: string; + email?: string; + country_code?: number; + phone_number?: string; + address?: string; + website?: string; + instagram?: string; + facebook?: string; + x?: string; +} + +export function checkAndTransformMarketDirectoryInfo() { + return async ( + req: LiteFarmRequest, + res: Response, + next: NextFunction, + ) => { + if (req.method === 'POST') { + // @ts-expect-error: TS doesn't see query() through softDelete HOC; safe at runtime + const record = await MarketDirectoryInfoModel.query() + .where({ farm_id: req.headers.farm_id }) + .first(); + + if (record) { + return res.status(409).send('Market directory info for this farm already exists'); + } + } + + const { address, website } = req.body; + + for (const emailProperty of ['contact_email', 'email'] as const) { + if (req.body[emailProperty] && !isValidEmail(req.body[emailProperty])) { + return res.status(400).send(`Invalid ${emailProperty}`); + } + } + + if (address && !(await isValidAddress(address))) { + return res.status(400).send('Invalid address'); + } + + if (website && !(await isValidUrl(website)) /* TODO: LF-5011 */) { + return res.status(400).send('Invalid website'); + } + + for (const social of SOCIALS.filter((social) => req.body[social]?.trim())) { + const socialUsername = validateSocialAndExtractUsername(social, req.body[social]!); + + if (!socialUsername) { + return res.status(400).send(`Invalid ${social}`); + } + req.body[social] = socialUsername; + } + + next(); + }; +} diff --git a/packages/api/src/models/marketDirectoryInfoModel.js b/packages/api/src/models/marketDirectoryInfoModel.js index 476630295c..8a9107125e 100644 --- a/packages/api/src/models/marketDirectoryInfoModel.js +++ b/packages/api/src/models/marketDirectoryInfoModel.js @@ -14,6 +14,7 @@ */ import baseModel from './baseModel.js'; +import { checkAndTrimString } from '../util/util.js'; class MarketDirectoryInfo extends baseModel { static get tableName() { @@ -24,6 +25,34 @@ class MarketDirectoryInfo extends baseModel { return 'id'; } + static get stringProperties() { + const stringProperties = []; + for (const [key, value] of Object.entries(this.jsonSchema.properties)) { + if (value.type.includes('string')) { + stringProperties.push(key); + } + } + return stringProperties; + } + + async $beforeInsert(queryContext) { + await super.$beforeInsert(queryContext); + this.trimStringProperties(); + } + + async $beforeUpdate(opt, queryContext) { + await super.$beforeUpdate(opt, queryContext); + this.trimStringProperties(); + } + + trimStringProperties() { + for (const key of this.constructor.stringProperties) { + if (key in this) { + this[key] = checkAndTrimString(this[key]); + } + } + } + // Optional JSON schema. This is not the database schema! Nothing is generated // based on this. This is only used for validation. Whenever a model instance // is created it is checked against this schema. http://json-schema.org/. @@ -36,7 +65,7 @@ class MarketDirectoryInfo extends baseModel { farm_id: { type: 'string' }, farm_name: { type: 'string', minLength: 1, maxLength: 255 }, logo: { type: ['string', 'null'], maxLength: 255 }, - about: { type: ['string', 'null'] }, + about: { type: ['string', 'null'], maxLength: 3000 }, contact_first_name: { type: 'string', minLength: 1, maxLength: 255 }, contact_last_name: { type: ['string', 'null'], maxLength: 255 }, contact_email: { type: 'string', minLength: 1, maxLength: 255 }, @@ -44,7 +73,7 @@ class MarketDirectoryInfo extends baseModel { country_code: { type: ['integer', 'null'], minimum: 1, maximum: 999 }, phone_number: { type: ['string', 'null'], maxLength: 255 }, address: { type: 'string', minLength: 1, maxLength: 255 }, - website: { type: ['string', 'null'] }, + website: { type: ['string', 'null'], maxLength: 2000 }, instagram: { type: ['string', 'null'], maxLength: 255 }, facebook: { type: ['string', 'null'], maxLength: 255 }, x: { type: ['string', 'null'], maxLength: 255 }, diff --git a/packages/api/src/routes/marketDirectoryInfoRoute.ts b/packages/api/src/routes/marketDirectoryInfoRoute.ts new file mode 100644 index 0000000000..cbfcf35fd5 --- /dev/null +++ b/packages/api/src/routes/marketDirectoryInfoRoute.ts @@ -0,0 +1,30 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import express from 'express'; +import checkScope from '../middleware/acl/checkScope.js'; +import { checkAndTransformMarketDirectoryInfo } from '../middleware/validation/checkMarketDirectoryInfo.js'; +import MarketDirectoryInfoController from '../controllers/marketDirectoryInfoController.js'; + +const router = express.Router(); + +router.post( + '/', + checkScope(['add:market_directory_info']), + checkAndTransformMarketDirectoryInfo(), + MarketDirectoryInfoController.addMarketDirectoryInfo(), +); + +export default router; diff --git a/packages/api/src/server.ts b/packages/api/src/server.ts index db570b3285..8bd6e20fbd 100644 --- a/packages/api/src/server.ts +++ b/packages/api/src/server.ts @@ -175,6 +175,7 @@ import farmAddonRoute from './routes/farmAddonRoute.js'; import weatherRoute from './routes/weatherRoute.js'; import irrigationPrescriptionRoute from './routes/irrigationPrescriptionRoute.js'; import irrigationPrescriptionRequestRoute from './routes/irrigationPrescriptionRequestRoute.js'; +import marketDirectoryInfoRoute from './routes/marketDirectoryInfoRoute.js'; // register API const router = promiseRouter(); @@ -343,7 +344,8 @@ app .use('/farm_addon', farmAddonRoute) .use('/weather', weatherRoute) .use('/irrigation_prescriptions', irrigationPrescriptionRoute) - .use('/irrigation_prescription_request', irrigationPrescriptionRequestRoute); + .use('/irrigation_prescription_request', irrigationPrescriptionRequestRoute) + .use('/market_directory_info', marketDirectoryInfoRoute); // Allow a 1MB limit on sensors to match incoming Ensemble data app.use('/sensor', express.json({ limit: '1MB' }), rejectBodyInGetAndDelete, sensorRoute); diff --git a/packages/api/src/types.ts b/packages/api/src/types.ts index d2b5ba7eda..91721468c2 100644 --- a/packages/api/src/types.ts +++ b/packages/api/src/types.ts @@ -21,8 +21,12 @@ export interface HttpError extends Error { } // TODO: Remove farm_id conditional and cast this in a checkScope() that takes the function and casts this to req -export interface LiteFarmRequest - extends Request { +export interface LiteFarmRequest< + QueryParams = unknown, + RouteParams = unknown, + ResBody = unknown, + ReqBody = unknown, +> extends Request { headers: Request['headers'] & { farm_id?: string; }; diff --git a/packages/api/src/util/googleMaps.ts b/packages/api/src/util/googleMaps.ts new file mode 100644 index 0000000000..b41db6ff95 --- /dev/null +++ b/packages/api/src/util/googleMaps.ts @@ -0,0 +1,31 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { Client } from '@googlemaps/google-maps-services-js'; +const googleClient = new Client({}); + +export async function getAddressComponents(address: string) { + try { + const response = await googleClient.geocode({ + params: { + address, + key: process.env.GOOGLE_API_KEY!, + }, + }); + return response.data.results[0]?.address_components; + } catch (error) { + console.error(error); + } +} diff --git a/packages/api/src/util/socials.ts b/packages/api/src/util/socials.ts new file mode 100644 index 0000000000..0d1436aadc --- /dev/null +++ b/packages/api/src/util/socials.ts @@ -0,0 +1,70 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +export enum Social { + INSTAGRAM = 'instagram', + FACEBOOK = 'facebook', + X = 'x', +} + +export const SOCIAL_DOMAINS = { + [Social.INSTAGRAM]: 'instagram.com', + [Social.FACEBOOK]: 'facebook.com', + [Social.X]: 'x.com', +}; + +export const SOCIALS = Object.values(Social); + +/** + * Validates a social username or URL and extracts the username. + * + * This function handles: + * - Full URLs with optional scheme (http/https) and optional "www." + * e.g., "https://www.instagram.com/username/?hl=en", "http://instagram.com/username/#" + * - Direct usernames with or without a leading "@" + * e.g., "username", "@username" + * + * It rejects: + * - Domain-only inputs, e.g., "instagram.com" or "www.instagram.com" + * - Usernames containing invalid characters (only A-Z, a-z, 0-9, ".", "_", "-" are allowed) + */ +export const validateSocialAndExtractUsername = (social: Social, usernameOrUrl: string) => { + const trimmedInput = usernameOrUrl.trim(); + const domain = SOCIAL_DOMAINS[social]; + + // reject if it’s just the url without username + if (new RegExp(`^(https?://)?(www.)?${domain}/?$`).test(trimmedInput)) { + return false; + } + + // Match URL: [http(s)://][www.]domain/{username}[optional trailing path or query] + // Capture groups: + // 1: http(s):// (optional) + // 2: www. (optional) + // 3: username ([A-Za-z0-9._-]+) + // 4: trailing path/query (optional) ([/?#].*)? e.g., "/?hl=en", "/#" + const urlMatch = trimmedInput.match( + new RegExp(`^(https?://)?(www.)?${domain}/([A-Za-z0-9._-]+)([/?#].*)?$`), + ); + + if (urlMatch) { + return urlMatch[3]; + } + + const username = trimmedInput.replace(/^@/, ''); + + // validate allowed characters + return /^[A-Za-z0-9._-]+$/.test(username) ? username : false; +}; diff --git a/packages/api/src/util/url.ts b/packages/api/src/util/url.ts new file mode 100644 index 0000000000..ed915f9d49 --- /dev/null +++ b/packages/api/src/util/url.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +export const isValidUrl = async (url: string) => { + // TODO: LF-5011 + return !!url; +}; diff --git a/packages/api/src/util/validation.ts b/packages/api/src/util/validation.ts index edabd4739d..a4d4db64b8 100644 --- a/packages/api/src/util/validation.ts +++ b/packages/api/src/util/validation.ts @@ -13,6 +13,8 @@ * GNU General Public License for more details, see . */ +import { getAddressComponents } from './googleMaps.js'; + /** * AI-assisted documentation * @@ -51,3 +53,13 @@ export function isSimpleDateFormat(value: unknown): boolean { return typeof value === 'string' && simpleDateRegex.test(value); } + +const VALID_EMAIL_REGEX = /^$|^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,6}$/i; + +export const isValidEmail = (email: string): boolean => { + return VALID_EMAIL_REGEX.test(email); +}; + +export async function isValidAddress(address: string) { + return !!(await getAddressComponents(address)); +} diff --git a/packages/api/tests/marketDirectoryInfo.test.ts b/packages/api/tests/marketDirectoryInfo.test.ts new file mode 100644 index 0000000000..22999c416b --- /dev/null +++ b/packages/api/tests/marketDirectoryInfo.test.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import chai from 'chai'; +import { faker } from '@faker-js/faker'; + +import chaiHttp from 'chai-http'; +chai.use(chaiHttp); + +import server from '../src/server.js'; +import knex from '../src/util/knex.js'; +import { tableCleanup } from './testEnvironment.js'; + +jest.mock('jsdom'); +jest.mock('../src/middleware/acl/checkJwt.js', () => + jest.fn((req, _res, next) => { + req.auth = {}; + req.auth.user_id = req.get('user_id'); + next(); + }), +); + +const INVALID_SUFFIX = '_INVALID'; + +jest.mock('../src/util/googleMaps.js', () => ({ + getAddressComponents: async (address: string) => { + return address.endsWith(INVALID_SUFFIX) ? undefined : [{}]; + }, +})); + +jest.mock('../src/util/url.js', () => ({ + isValidUrl: async (url: string) => { + return !url.endsWith(INVALID_SUFFIX); + }, +})); + +import mocks from './mock.factories.js'; +import { createUserFarmIds } from './utils/testDataSetup.js'; +import { MarketDirectoryInfoReqBody } from '../src/middleware/validation/checkMarketDirectoryInfo.js'; +import MarketDirectoryInfoModel from '../src/models/marketDirectoryInfoModel.js'; +import { SOCIAL_DOMAINS } from '../src/util/socials.js'; +import { HeadersParams } from './types.js'; + +const marketDirectoryInfo = mocks.fakeMarketDirectoryInfo({ + logo: faker.internet.url(), + about: faker.lorem.sentences(), + contact_last_name: faker.name.lastName(), + email: faker.internet.email(), + country_code: Math.floor(Math.random() * 999) + 1, + phone_number: faker.phone.phoneNumber(), + website: faker.internet.url(), + instagram: faker.internet.userName(), + facebook: faker.internet.userName(), + x: faker.internet.userName(), +}); + +const fakeInvalidString = (input: string = '') => `${input}${INVALID_SUFFIX}`; + +const invalidTestCases = [ + ['address', fakeInvalidString(faker.address.streetAddress())], + ['website', fakeInvalidString(faker.internet.url())], + ['contact_email', faker.lorem.word()], + ['email', faker.lorem.word()], + ['instagram', SOCIAL_DOMAINS['instagram']], // domain without username + ['facebook', `/${faker.internet.userName()}`], // username with invalid character + ['x', `https://${SOCIAL_DOMAINS['x']}/username!}`], // url with invalid username +]; + +async function postRequest(data: MarketDirectoryInfoReqBody, { user_id, farm_id }: HeadersParams) { + return chai + .request(server) + .post('/market_directory_info') + .set('content-type', 'application/json') + .set('user_id', user_id) + .set('farm_id', farm_id) + .send(data); +} + +describe('Market Directory Info Tests', () => { + afterAll(async () => { + await tableCleanup(knex); + await knex.destroy(); + }); + + describe('Post Market Directory Info', () => { + test('Admin users should be able to create a market directory info', async () => { + const adminRoles = [1, 2, 5]; + + for (const role of adminRoles) { + const userFarmIds = await createUserFarmIds(role); + const res = await postRequest(marketDirectoryInfo, userFarmIds); + + expect(res.status).toBe(201); + + // @ts-expect-error: TS doesn't see query() through softDelete HOC; safe at runtime + const record = await MarketDirectoryInfoModel.query() + .where('farm_id', userFarmIds.farm_id) + .first(); + + for (const property in marketDirectoryInfo) { + const key = property as keyof typeof marketDirectoryInfo; + expect(record[key]).toBe(marketDirectoryInfo[key]); + } + } + }); + + test('Worker should not be able to create a market directory info', async () => { + const userFarmIds = await createUserFarmIds(3); + const res = await postRequest(marketDirectoryInfo, userFarmIds); + + expect(res.status).toBe(403); + }); + + test('Should return 409 conflict if market directory info already exists', async () => { + const userFarmIds = await createUserFarmIds(1); + await mocks.market_directory_infoFactory({ + promisedUserFarm: Promise.resolve([userFarmIds]), + }); + + const res = await postRequest(marketDirectoryInfo, userFarmIds); + expect(res.status).toBe(409); + }); + + describe('Should return 400 for invalid data', () => { + test.each(invalidTestCases)('%s', async (property, data) => { + const userFarmIds = await createUserFarmIds(1); + const res = await postRequest({ ...marketDirectoryInfo, [property]: data }, userFarmIds); + + expect(res.status).toBe(400); + + if (!res.error) { + throw new Error('Expected an error'); // type guard + } + + expect(res.error.text).toBe(`Invalid ${property}`); + }); + }); + }); +}); diff --git a/packages/api/tests/mock.factories.js b/packages/api/tests/mock.factories.js index 8242b10168..218ed9890f 100644 --- a/packages/api/tests/mock.factories.js +++ b/packages/api/tests/mock.factories.js @@ -22,7 +22,7 @@ async function usersFactory(userObject = fakeUser()) { function fakeUser(defaultData = {}) { const email = faker.lorem.word() + faker.internet.email(); return { - first_name: faker.name.findName(), + first_name: faker.name.firstName(), last_name: faker.name.lastName(), email: email.toLowerCase(), user_id: faker.datatype.uuid(), @@ -38,7 +38,7 @@ function fakeUser(defaultData = {}) { function fakeSSOUser(defaultData = {}) { const email = faker.lorem.word() + faker.internet.email(); return { - first_name: faker.name.findName(), + first_name: faker.name.firstName(), last_name: faker.name.lastName(), email: email.toLowerCase(), user_id: faker.datatype.number({ min: 2, max: 10 }), @@ -1935,7 +1935,7 @@ async function saleFactory({ promisedUserFarm = userFarmFactory() } = {}, sale = function fakeSale(defaultData = {}) { return { - customer_name: faker.name.findName(), + customer_name: faker.name.firstName(), sale_date: faker.date.recent(), ...defaultData, }; @@ -2697,6 +2697,29 @@ async function farm_addonFactory({ .returning('*'); } +function fakeMarketDirectoryInfo(defaultData = {}) { + return { + farm_name: faker.lorem.word(), + contact_first_name: faker.name.firstName(), + contact_email: faker.internet.email(), + address: faker.address.streetAddress(), + ...defaultData, + }; +} + +async function market_directory_infoFactory({ + promisedUserFarm = userFarmFactory(), + marketDirectoryInfo = fakeMarketDirectoryInfo(), +} = {}) { + const [userFarm] = await Promise.all([promisedUserFarm]); + const [{ farm_id, user_id }] = userFarm; + const base = baseProperties(user_id); + + return await knex('market_directory_info') + .insert({ farm_id, ...base, ...marketDirectoryInfo }) + .returning('*'); +} + // External endpoint helper mocks export const buildExternalIrrigationPrescription = async ({ id, @@ -2924,5 +2947,7 @@ export default { farm_addonFactory, buildExternalIrrigationPrescription, buildIrrigationPrescription, + fakeMarketDirectoryInfo, + market_directory_infoFactory, baseProperties, }; diff --git a/packages/api/tests/testEnvironment.js b/packages/api/tests/testEnvironment.js index c4625a4f4f..7f5e503412 100644 --- a/packages/api/tests/testEnvironment.js +++ b/packages/api/tests/testEnvironment.js @@ -146,6 +146,7 @@ async function tableCleanup(knex) { DELETE FROM "animal_removal_reason"; DELETE FROM "farm_addon"; DELETE FROM "addon_partner"; + DELETE FROM "market_directory_info"; DELETE FROM "location"; DELETE FROM "userFarm"; DELETE FROM "farm"; diff --git a/packages/api/tests/types.ts b/packages/api/tests/types.ts new file mode 100644 index 0000000000..690714ed09 --- /dev/null +++ b/packages/api/tests/types.ts @@ -0,0 +1,19 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +export interface HeadersParams { + user_id: string; + farm_id: string; +} diff --git a/packages/api/tests/utils-tests/socials.test.ts b/packages/api/tests/utils-tests/socials.test.ts new file mode 100644 index 0000000000..73c4b216aa --- /dev/null +++ b/packages/api/tests/utils-tests/socials.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright 2025 LiteFarm.org + * This file is part of LiteFarm. + * + * LiteFarm is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * LiteFarm is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details, see . + */ + +import { faker } from '@faker-js/faker'; +import { + SOCIAL_DOMAINS, + SOCIALS, + validateSocialAndExtractUsername, +} from '../../src/util/socials.js'; + +const social1 = SOCIALS[0]; +const social2 = SOCIALS[1]; +const domain = SOCIAL_DOMAINS[social1]; +const username = faker.internet.userName(); + +const invalidUsernames = [`/username`, 'user?name', 'username!', '@user@name']; + +const validUrls = [ + `${domain}/${username}`, + ` ${domain}/${username} `, + `http://${domain}/${username}`, + `https://${domain}/${username}`, + `https://${domain}/${username}/`, + `www.${domain}/${username}`, + `http://www.${domain}/${username}`, + `https://www.${domain}/${username}`, + `https://${domain}/${username}/#`, + `https://${domain}/${username}/?hl=en`, + `https://${domain}/${username}/valid_url_example`, +]; + +const invalidUrls = [ + `${domain}`, + `${domain}/`, + `www.${domain}`, + `www.${domain}/`, + `https://${domain}`, + `https://${domain}/`, + `https://${domain}/?`, + `https://${domain}/#`, + `https://${domain}/ `, + `https://${domain}/https://${domain}/${username}`, + `http://${SOCIAL_DOMAINS[social2]}/${username}`, + `https://${SOCIAL_DOMAINS[social2]}/${username}`, + `https://www.${SOCIAL_DOMAINS[social2]}/${username}`, +]; + +describe('Test socials', () => { + describe('validateSocialAndExtractUsername', () => { + test('Returns username unchanged when input is plain username', () => { + const result = validateSocialAndExtractUsername(social1, username); + expect(result).toBe(username); + }); + + test('Strips @ prefix from username', () => { + const result = validateSocialAndExtractUsername(social1, `@${username}`); + expect(result).toBe(username); + }); + + describe('Return false for username with invalid character', () => { + test.each(invalidUsernames)('%s', (invalidUsername) => { + const result = validateSocialAndExtractUsername(social1, invalidUsername); + expect(result).toBe(false); + }); + }); + + describe('Extract username for valid URL', () => { + test.each(validUrls)('%s', (url) => { + const result = validateSocialAndExtractUsername(social1, url); + expect(result).toBe(username); + }); + }); + + describe('Return false for invalid URL', () => { + test.each(invalidUrls)('%s', (url) => { + const result = validateSocialAndExtractUsername(social1, url); + expect(result).toBe(false); + }); + }); + }); +}); diff --git a/packages/api/tests/utils/testDataSetup.ts b/packages/api/tests/utils/testDataSetup.ts index 25c9ba8027..13fd94905d 100644 --- a/packages/api/tests/utils/testDataSetup.ts +++ b/packages/api/tests/utils/testDataSetup.ts @@ -17,6 +17,7 @@ import knex from '../../src/util/knex.js'; import mocks from '../mock.factories.js'; import LocationModel from '../../src/models/locationModel.js'; import { Farm, Location, User } from '../../src/models/types.js'; +import { HeadersParams } from '../types.js'; /** * Generates a fake user farm object with the specified role. @@ -42,6 +43,15 @@ export async function returnUserFarms(role: number): Promise<{ mainFarm: Farm; u return { mainFarm, user }; } +/** + * Creates a farm and a user, associates them using the given role, + * and returns only their IDs for use in requests or headers. + */ +export async function createUserFarmIds(role: number): Promise { + const { mainFarm, user } = await returnUserFarms(role); + return { farm_id: mainFarm.farm_id, user_id: user.user_id }; +} + /** * Sets up the farm environment by creating a farm, owner, field, and (optionally) a non-owner user (if role id is provided) */