Разработка слоя репозиториев web-приложения
Вам необходимо вручную создать SQL-таблицы, отражающие структуру социальной сети GopherTalk. Ниже описаны таблицы, их поля и связи между ними.
- Таблица
users
— хранит данные пользователей. - Таблица
posts
— хранит публикации пользователей. Полеreply_to_id
указывает на другой пост, если это ответ. - Таблица
likes
— отображает лайки пользователей к постам. - Таблица
views
— отображает просмотры постов пользователями.
Требования:
- Используйте типы данных и ограничения согласно описанию.
- Настройте первичные и внешние ключи.
- Создайте уникальный индекс по
user_name
, но только для не удалённых пользователей (deleted_at IS NULL
). - Убедитесь, что
status
может быть только0
или1
.
Подсказка: после создания таблиц, проверьте схему с помощью ER-диаграммы, чтобы убедиться в корректности связей.
Архитектура приложения: контроллеры, сервисы и репозитории
Когда приложение начинает расти, добавляется всё больше бизнес-логики, валидации, работы с базой данных — и код быстро превращается в нечитаемую "кашу". Чтобы этого избежать, используется разделение ответственности — принцип, при котором каждый компонент отвечает только за свою задачу.
В небольших веб-приложениях удобно придерживаться следующей архитектуры:
1. Контроллеры (controllers)
Контроллер — это слой, который принимает HTTP-запрос, обрабатывает его и возвращает ответ. Здесь происходит:
- чтение параметров из
req
, - вызов нужного метода сервиса,
- формирование ответа (
res.status().json(...)
).
Контроллер не содержит бизнес-логики и не обращается напрямую к базе данных — он просто управляет потоком данных. Кроме того, на уровне контроллера решаются вопросы по разграничению доступа к ресурсам и фильтрации запросов.
2. Сервисы (services)
Сервис — это слой, где находится основная бизнес-логика приложения. Он:
- обрабатывает данные,
- проверяет условия (например, "пользователь уже существует"),
- вызывает репозиторий для доступа к базе.
Сервис ничего не знает про req
и res
— он универсален и может использоваться как в HTTP-приложении, так и, например, в CLI-утилите или фоновом скрипте.
3. Репозитории (repositories)
Репозиторий — это слой, отвечающий за доступ к данным. Обычно здесь хранятся SQL-запросы.
Сервис говорит: "дай мне пользователя по id", а репозиторий выполняет конкретный SQL-запрос и возвращает результат.
Такой подход позволяет:
- изолировать работу с базой,
- легче писать и запускать юнит-тесты,
- менять способ хранения данных (например, заменить PostgreSQL на MongoDB) с минимальными изменениями.
Преимущества архитектуры:
- Код становится чище, понятнее и масштабируемее;
- Каждый слой можно тестировать отдельно;
- Упрощается командная разработка — каждый работает в своей зоне ответственности;
- Легче поддерживать и расширять приложение в будущем.
В соответсвии с архитектурой мы построим разработку следующим образом: сначала разработаем слой репозитория, затем слой сервисов и в конце слой контроллеров. Для каждого слоя напишем Вам будут предоставлены unit-тесты для проверки корректности разработки конкретного слоя.
Разработка репозитория пользователей
На этом этапе мы реализуем слой работы с базой данных — репозиторий пользователей.
Задача этого слоя — обеспечивать сохранение, получение, обновление и удаление данных пользователей без участия бизнес-логики или HTTP-контроллеров.
Репозиторий будет включать методы:
- добавления нового пользователя,
- получения всех пользователей с пагинацией,
- поиска пользователя по
id
и поuser_name
, - обновления данных пользователя,
- мягкого удаления пользователя.
Мы начнем с самого простого метода — createUser
, который сохраняет нового пользователя в таблице users
.
Затем реализуем остальные методы и подключим unit-тесты для проверки корректности.
В папке src
проекта создайте папку repositories
, а в ней файл userRepository.js
. Поместите в него следующий код:
import { pool } from "../db/index.js";
export const UserRepository = {
async createUser(dto) {
const query = `
INSERT INTO users (user_name, first_name, last_name, password_hash)
VALUES ($1, $2, $3, $4)
RETURNING id, user_name, password_hash, status;
`;
const values = [
dto.user_name,
dto.first_name,
dto.last_name,
dto.password_hash,
];
const res = await pool.query(query, values);
return res.rows[0];
},
};
Этот код реализует метод createUser
в объекте UserRepository
, который отвечает за добавление нового пользователя в базу данных.
Пошаговый разбор
Импорт подключения к базе данных:
jsimport { pool } from "../db/index.js";
Здесь импортируется объект
pool
, который представляет пул подключений к базе данных PostgreSQL. Он уже настроен в другом модуле (db/index.js
) и позволяет выполнять SQL-запросы.Экспорт объекта
UserRepository
:jsexport const UserRepository = { ... }
Определение метода
createUser
:jsasync createUser(dto) { ... }
Метод
createUser
— асинхронная функция, которая принимает объектdto
(data transfer object) с полями нового пользователя. В нашем случае этоuser_name
,first_name
,last_name
,password_hash
.SQL-запрос на вставку
jsconst query = ` INSERT INTO users (user_name, first_name, last_name, password_hash) VALUES ($1, $2, $3, $4) RETURNING id, user_name, password_hash, status; `;
Это SQL-запрос, который вставляет нового пользователя в таблицу
users
. Используются подстановки$1
,$2
,$3
,$4
— это позиционные параметры (предотвращают SQL-инъекции). После вставки сразу возвращаются данные нового пользователя: егоid
,user_name
,password_hash
иstatus
.SQL-инъекции
SQL-инъекция — это один из самых распространённых видов атак на базу данных. Она возникает, когда ввод пользователя напрямую вставляется в SQL-запрос без проверки и экранирования, что позволяет злоумышленнику изменить логику запроса.
Пример уязвимого кода:
jsconst userInput = "' OR 1=1 --"; const query = `SELECT * FROM users WHERE user_name = '${userInput}'`;
Вместо ожидаемого безопасного значения, пользователь ввёл строку
' OR 1=1 --
.В результате итоговый SQL-запрос будет выглядеть так:
sqlSELECT * FROM users WHERE user_name = '' OR 1=1 --';
Что здесь происходит:
user_name = ''
— первое условие, оно просто проверяет, что имя пользователя пустое;OR 1=1
— логическое выражение, которое всегда истинно, то есть условие выполняется для всех пользователей;--
— начало SQL-комментария, всё, что идёт после него, игнорируется СУБД;';
— эта часть уже не исполняется, так как закомментирована.
Этот запрос вернёт всех пользователей из базы, потому что
1=1
всегда истинно. Если такой запрос используется при входе в систему, злоумышленник может войти без пароля, просто потому что запрос "обманывает" проверку логина.Используя позиционные параметры, мы избегаем этой проблемы:
jsconst query = "SELECT * FROM users WHERE user_name = $1"; const values = [userInput]; await pool.query(query, values);
В случае использования позиционных параметров, даже если пользователь введёт
' OR 1=1 --
, это не приведёт к SQL-инъекции, потому что ввод не вставляется напрямую в текст SQL-запроса. Вместо этого он передаётся отдельно в виде значения, а не как часть кода, а на уровне драйвера PostgreSQL (pg
) реализован механизм, который:экранирует специальные символы,
оборачивает значение в кавычки при необходимости,
и гарантирует, что ввод будет интерпретироваться именно как строка, а не как SQL-операторы.
Проще говоря, драйвер сам "разделяет" SQL-код и пользовательские данные, не давая последним повлиять на логику выполнения запроса.
Поэтому даже вредоносная строка будет просто передана как обычное значение поля
user_name
, а не как часть SQL-запроса.Подготовка значений для запроса:
jsconst values = [ dto.user_name, dto.first_name, dto.last_name, dto.password_hash, ];
Значения берутся из входного объекта
dto
и передаются в том порядке, в котором указаны в SQL-запросе.Выполнение запроса
jsconst res = await pool.query(query, values);
Запрос выполняется с помощью метода
pool.query(...)
. Он асинхронный, поэтому используетсяawait
. Результат сохраняется в переменнойres
.Возврат результата:
jsreturn res.rows[0];
После выполнения запроса возвращается первая (и единственная) строка результата — то есть данные только что созданного пользователя.
Мы сделали создание пользователя. Также необходимо реализовать методы:
getAllUsers
- получение списка всех пользователей с пагинацией,getUserById
- получение пользователя по его id,getUserByUserName
- получение пользователя по его имени пользователя,updateUser
- обновление данных пользователя,deleteUser
- удаление пользователя
Реализуем метод getAllUsers
.
import { pool } from "../db/index.js";
export const UserRepository = {
async createUser(dto) {
const query = `
INSERT INTO users (user_name, first_name, last_name, password_hash)
VALUES ($1, $2, $3, $4)
RETURNING id, user_name, password_hash, status;
`;
const values = [
dto.user_name,
dto.first_name,
dto.last_name,
dto.password_hash,
];
const res = await pool.query(query, values);
return res.rows[0];
},
async getAllUsers(limit, offset) {
const query = `
SELECT id, user_name, first_name, last_name, status, created_at, updated_at
FROM users
WHERE deleted_at IS NULL
OFFSET $1 LIMIT $2;
`;
const res = await pool.query(query, [offset, limit]);
return res.rows;
},
};
Обратите внимание, что метод принимает два параметра - offset
и limit
. Они необходимы для того, чтобы сделать пагинацию, то есть отдавать не всех пользователей сразу, а частями в рамках скользящего окна.
Перейдем к методам getUserById
и getUserByUserName
.
import { pool } from "../db/index.js";
export const UserRepository = {
async createUser(dto) {
const query = `
INSERT INTO users (user_name, first_name, last_name, password_hash)
VALUES ($1, $2, $3, $4)
RETURNING id, user_name, password_hash, status;
`;
const values = [
dto.user_name,
dto.first_name,
dto.last_name,
dto.password_hash,
];
const res = await pool.query(query, values);
return res.rows[0];
},
async getAllUsers(limit, offset) {
const query = `
SELECT id, user_name, first_name, last_name, status, created_at, updated_at
FROM users
WHERE deleted_at IS NULL
OFFSET $1 LIMIT $2;
`;
const res = await pool.query(query, [offset, limit]);
return res.rows;
},
async getUserById(id) {
const query = `...`;
const res = await pool.query(query, [id]);
if (res.rowCount === 0) {
throw new Error("User not found");
}
return res.rows[0];
},
async getUserByUserName(user_name) {
const query = `...`;
const res = await pool.query(query, [user_name]);
if (res.rowCount === 0) {
throw new Error("User not found");
}
return res.rows[0];
},
};
Задание
Напишите самостоятельно SQL запросы для методов getUserById
и getUserByUserName
. Для метода getUserById
необходимо вернуть поля user_name
, first_name
, last_name
, status
, created_at
, updated_at
, а для метода getUserByUserName
- user_name
, password_hash
, status
.
Рассмотрим метод updateUser
async updateUser(id, dto) {
const fields = [];
const args = [];
let index = 1;
if (dto.password_hash) {
fields.push(`password_hash = $${index++}`);
args.push(dto.password_hash);
}
if (dto.user_name) {
fields.push(`user_name = $${index++}`);
args.push(dto.user_name);
}
if (dto.first_name) {
fields.push(`first_name = $${index++}`);
args.push(dto.first_name);
}
if (dto.last_name) {
fields.push(`last_name = $${index++}`);
args.push(dto.last_name);
}
if (fields.length === 0) {
throw new Error("No fields to update");
}
fields.push(`updated_at = NOW()`);
const query = `
UPDATE users SET ${fields.join(", ")}
WHERE id = $${index} AND deleted_at IS NULL
RETURNING id, user_name, first_name, last_name, status, created_at, updated_at;
`;
args.push(id);
const res = await pool.query(query, args);
if (res.rowCount === 0) {
throw new Error("User not found");
}
return res.rows[0];
}
Этот асинхронный метод предназначен для обновления данных пользователя в базе данных. Он принимает два аргумента:
id
: Идентификатор пользователя, которого необходимо обновить.dto
: Объект, содержащий данные для обновления.
Логика работы:
Инициализация:
- Создаются два массива:
fields
для хранения строк с обновлениями полей (field = $index
) иargs
для хранения значений, которые будут подставлены в запрос. index
инициализируется значением1
. Эта переменная используется для генерации плейсхолдеров$1
,$2
и т.д. в SQL-запросе.
- Создаются два массива:
Проверка полей для обновления:
- Выполняется последовательная проверка наличия полей в объекте
dto
и добавление соответствующих данных в массивыfields
иargs
:password_hash
: Если присутствует, добавляетсяpassword_hash = $index
вfields
и значениеdto.password_hash
вargs
.user_name
: Аналогично для имени пользователя.first_name
: Аналогично для имени.last_name
: Аналогично для фамилии.
- При каждом добавлении поля
index
увеличивается.
- Выполняется последовательная проверка наличия полей в объекте
Проверка наличия полей для обновления:
- Если массив
fields
пуст (то есть вdto
не было полей для обновления), выбрасывается исключениеError("No fields to update")
.
- Если массив
Добавление поля
updated_at
:- В массив
fields
добавляется строкаupdated_at = NOW()
, которая обновит полеupdated_at
текущим временем.
- В массив
Формирование SQL-запроса:
- Формируется SQL-запрос для обновления данных пользователя.
- Используется конструкция
UPDATE users SET ${fields.join(", ")}
, гдеfields.join(", ")
объединяет строки с обновлениями полей в одну строку, разделенную запятыми. - Условие
WHERE id = $index AND deleted_at IS NULL
указывает, что обновлять нужно пользователя с заданнымid
, который не помечен как удаленный (deleted_at IS NULL
). - Конструкция
RETURNING id, user_name, first_name, last_name, status, created_at, updated_at
возвращает данные обновленного пользователя.
Добавление
id
пользователя в аргументы запроса:- В массив
args
добавляетсяid
пользователя, который будет использоваться в условииWHERE id = $index
.
- В массив
Выполнение запроса:
- Выполняется SQL-запрос с использованием
pool.query(query, args)
. Результат запроса сохраняется в переменнойres
.
- Выполняется SQL-запрос с использованием
Обработка результата запроса:
- Если
res.rowCount === 0
, то есть не было найдено ни одного пользователя для обновления, выбрасывается исключениеError("User not found")
. - В противном случае возвращается первая строка результата запроса (
res.rows[0]
), содержащая данные обновленного пользователя.
- Если
Последний метод, который мы реализуем в этом репозитории - это метод удаления пользователя deleteUser
.
async deleteUser(id) {
const query = `...`;
const res = await pool.query(query, [id]);
if (res.rowCount === 0) {
throw new Error("User not found");
}
}
Этот асинхронный метод предназначен для "удаления" пользователя из базы данных. Фактически, это мягкое удаление (soft delete), когда запись не удаляется физически, а лишь помечается как удалённая.
Задание
Напишите SQL-запрос, который выполняет мягкое удаление пользователя, устанавливая значение deleted_at
в текущее время для пользователя с указанным id
.
Тестирование репозитория пользователей
В корне проекта создайте папку tests
, а в ней подпапку repositories
.
В папке repositories
создайте файл test_user_repository.py
и поместите в него код с unit-тестами:
Unit-тесты userRepository
import { expect, jest } from "@jest/globals";
import { pool } from "../../src/config/db.js";
import { UserRepository } from "../../src/repositories/userRepository.js";
function normalizeSQL(sql) {
return sql.toLowerCase().replace(/\s+/g, " ").trim();
}
describe("UserRepository", () => {
afterEach(() => {
jest.clearAllMocks();
});
describe("createUser", () => {
it("successfully creates new user", async () => {
const mock = jest.spyOn(pool, "query");
const dto = {
user_name: "john",
first_name: "John",
last_name: "Doe",
password_hash: "password",
};
const expected = {
id: 1,
user_name: "john",
password_hash: "password",
status: 1,
};
mock.mockResolvedValueOnce({ rows: [expected], rowCount: 1 });
const result = await UserRepository.createUser(dto);
expect(result).toEqual(expected);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain(
"insert into users (user_name, first_name, last_name, password_hash)"
);
expect(normalizedSQL).toContain(
"returning id, user_name, password_hash, status"
);
expect(params).toEqual([
dto.user_name,
dto.first_name,
dto.last_name,
dto.password_hash,
]);
});
it("error on user insert", async () => {
const mock = jest.spyOn(pool, "query");
const dto = {
user_name: "john",
first_name: "John",
last_name: "Doe",
password_hash: "password",
};
const fakeError = new Error("insert failed");
mock.mockRejectedValueOnce(fakeError);
await expect(UserRepository.createUser(dto)).rejects.toThrow(
"insert failed"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain(
"insert into users (user_name, first_name, last_name, password_hash)"
);
expect(params).toEqual([
dto.user_name,
dto.first_name,
dto.last_name,
dto.password_hash,
]);
});
});
describe("getAllUsers", () => {
it("successfully gets all users", async () => {
const mock = jest.spyOn(pool, "query");
const now = new Date();
const expectedUsers = [
{
id: 1,
user_name: "john",
first_name: "John",
last_name: "Doe",
status: 1,
created_at: now,
updated_at: now,
},
{
id: 2,
user_name: "jane",
first_name: "Jane",
last_name: "Smith",
status: 1,
created_at: now,
updated_at: now,
},
];
mock.mockResolvedValueOnce({
rows: expectedUsers,
rowCount: expectedUsers.length,
});
const result = await UserRepository.getAllUsers(100, 0);
expect(result).toEqual(expectedUsers);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain(
"select id, user_name, first_name, last_name, status, created_at, updated_at from users where deleted_at is null"
);
expect(params).toEqual([0, 100]);
});
it("returns error", async () => {
const mock = jest.spyOn(pool, "query");
mock.mockRejectedValueOnce(new Error("SQL error"));
await expect(UserRepository.getAllUsers(100, 0)).rejects.toThrow(
"SQL error"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("from users where deleted_at is null");
expect(params).toEqual([0, 100]);
});
});
describe("getUserById", () => {
it("successfully gets user by id", async () => {
const mock = jest.spyOn(pool, "query");
const now = new Date();
const expected = {
id: 1,
user_name: "john",
first_name: "John",
last_name: "Doe",
status: 1,
created_at: now,
updated_at: now,
};
mock.mockResolvedValueOnce({ rows: [expected], rowCount: 1 });
const result = await UserRepository.getUserById(1);
expect(result).toEqual(expected);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain(
"from users where id = $1 and deleted_at is null"
);
expect(params).toEqual([1]);
});
it("returns error if user not found", async () => {
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await expect(UserRepository.getUserById(2)).rejects.toThrow(
"User not found"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain(
"from users where id = $1 and deleted_at is null"
);
expect(params).toEqual([2]);
});
});
describe("getUserByUserName", () => {
it("successfully gets user by username", async () => {
const mock = jest.spyOn(pool, "query");
const expected = {
id: 1,
user_name: "john",
password_hash: "password",
status: 1,
};
mock.mockResolvedValueOnce({ rows: [expected], rowCount: 1 });
const result = await UserRepository.getUserByUserName("john");
expect(result).toEqual(expected);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain(
"from users where user_name = $1 and deleted_at is null"
);
expect(params).toEqual(["john"]);
});
it("returns error if user not found", async () => {
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await expect(
UserRepository.getUserByUserName("notfound")
).rejects.toThrow("User not found");
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain(
"from users where user_name = $1 and deleted_at is null"
);
expect(params).toEqual(["notfound"]);
});
});
describe("updateUser", () => {
it("successfully updates user", async () => {
const mock = jest.spyOn(pool, "query");
const now = new Date();
const oneHourAgo = new Date(now.getTime() - 60 * 60 * 1000);
const id = 1;
const dto = {
user_name: "john_updated",
first_name: "John",
last_name: "Doe",
password_hash: "password",
};
const expected = {
id,
user_name: dto.user_name,
first_name: dto.first_name,
last_name: dto.last_name,
status: 1,
created_at: oneHourAgo,
updated_at: now,
};
mock.mockResolvedValueOnce({ rows: [expected], rowCount: 1 });
const result = await UserRepository.updateUser(id, dto);
expect(result).toEqual(expected);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("update users set");
expect(normalizedSQL).toContain("where id = $");
expect(normalizedSQL).toContain(
"returning id, user_name, first_name, last_name, status"
);
expect(params).toContain(dto.user_name);
expect(params).toContain(dto.password_hash);
expect(params).toContain(dto.first_name);
expect(params).toContain(dto.last_name);
expect(params).toContain(id);
});
it("returns error if no fields to update", async () => {
await expect(UserRepository.updateUser(1, {})).rejects.toThrow(
"No fields to update"
);
});
it("returns error if user not found", async () => {
const mock = jest.spyOn(pool, "query");
const dto = {
user_name: "ghost",
};
mock.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await expect(UserRepository.updateUser(999, dto)).rejects.toThrow(
"User not found"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("update users set");
expect(normalizedSQL).toContain("where id = $");
expect(params).toEqual(["ghost", 999]);
});
});
describe("deleteUser", () => {
it("successfully deletes user", async () => {
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rowCount: 1 });
await expect(UserRepository.deleteUser(1)).resolves.toBeUndefined();
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("update users set deleted_at = now()");
expect(normalizedSQL).toContain("where id = $1 and deleted_at is null");
expect(params).toEqual([1]);
});
it("returns error if user not found", async () => {
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rowCount: 0 });
await expect(UserRepository.deleteUser(2)).rejects.toThrow(
"User not found"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("update users set deleted_at = now()");
expect(params).toEqual([2]);
});
});
});
После этого выполните команду
npm run test
Если вы все сделали правильно, все тесты пройдены.
> gophertalk-backend-express@0.1.0 test
> node --experimental-vm-modules node_modules/jest/bin/jest.js
(node:50607) ExperimentalWarning: VM Modules is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
PASS __tests__/repositories/userRepository.test.js
UserRepository
createUser
✓ successfully creates new user (2 ms)
✓ error on user insert (2 ms)
getAllUsers
✓ successfully gets all users (1 ms)
✓ returns error
getUserById
✓ successfully gets user by id (1 ms)
✓ returns error if user not found
getUserByUserName
✓ successfully gets user by username
✓ returns error if user not found (1 ms)
updateUser
✓ successfully updates user
✓ returns error if no fields to update (1 ms)
✓ returns error if user not found
deleteUser
✓ successfully deletes user
✓ returns error if user not found
Test Suites: 1 passed, 1 total
Tests: 13 passed, 13 total
Snapshots: 0 total
Time: 0.138 s
Ran all test suites.
Разработка репозитория постов
На этом этапе мы реализуем репозиторий постов — слой, отвечающий за взаимодействие с таблицей posts
, а также связанными с ней таблицами likes
, views
и вложенными ответами (реплаями).
Репозиторий постов будет включать следующие методы:
- создание нового поста (
createPost
); - получение списка постов с фильтрацией и пагинацией (
getAllPosts
); - получение одного поста по
id
, включая автора, количество лайков, просмотров и ответов (getPostByID
); - удаление поста владельцем (
deletePost
); - отметка, что пользователь просмотрел пост (
viewPost
); - лайк/дизлайк поста (
likePost
,dislikePost
).
Мы начнем с реализации метода createPost
, затем последовательно опишем остальные. Все методы взаимодействуют с базой через SQL-запросы, используют подстановки для защиты от SQL-инъекций и возвращают данные в формате DTO.
Создайте файл src/repositories/postRepository.js
, в него поместите следующий код:
import { pool } from "../db/index.js";
export const PostRepository = {
async createPost(dto) {
const query = `...`;
const values = [dto.text, dto.user_id, dto.reply_to_id];
const res = await pool.query(query, values);
return res.rows[0];
},
};
Пояснение:
dto
— объект, содержащий данные нового поста (text
,user_id
,reply_to_id
);SQL-запрос вставляет данные в таблицу posts;
После вставки сразу возвращаются поля нового поста:
id
,text
,created_at
,reply_to_id
.
Задание
В соответствии с пояснением напишите SQL запрос для добавления нового поста. Не забудьте использовать позиционные параметры $1
, $2
, $3
— для предотвращения SQL-инъекций
Метод getAllPosts
возвращает список постов с расширенной информацией: количество лайков, просмотров, ответов, а также информацию о пользователе и отметках "нравится" и "просмотрено" от текущего пользователя.
async getAllPosts(dto) {
const params = [dto.user_id];
let query = `
WITH likes_count AS (
SELECT post_id, COUNT(*) AS likes_count
FROM likes GROUP BY post_id
),
views_count AS (
SELECT post_id, COUNT(*) AS views_count
FROM views GROUP BY post_id
),
replies_count AS (
SELECT reply_to_id, COUNT(*) AS replies_count
FROM posts WHERE reply_to_id IS NOT NULL GROUP BY reply_to_id
)
SELECT
p.id, p.text, p.reply_to_id, p.created_at,
u.id AS user_id, u.user_name, u.first_name, u.last_name,
COALESCE(lc.likes_count, 0) AS likes_count,
COALESCE(vc.views_count, 0) AS views_count,
COALESCE(rc.replies_count, 0) AS replies_count,
CASE WHEN l.user_id IS NOT NULL THEN true ELSE false END AS user_liked,
CASE WHEN v.user_id IS NOT NULL THEN true ELSE false END AS user_viewed
FROM posts p
JOIN users u ON p.user_id = u.id
LEFT JOIN likes_count lc ON p.id = lc.post_id
LEFT JOIN views_count vc ON p.id = vc.post_id
LEFT JOIN replies_count rc ON p.id = rc.reply_to_id
LEFT JOIN likes l ON l.post_id = p.id AND l.user_id = $1
LEFT JOIN views v ON v.post_id = p.id AND v.user_id = $1
WHERE p.deleted_at IS NULL
`;
if (dto.search) {
query += ` AND p.text ILIKE $${params.length + 1}`;
params.push(`%${dto.search}%`);
}
if (dto.owner_id) {
query += ` AND p.user_id = $${params.length + 1}`;
params.push(dto.owner_id);
}
if (dto.reply_to_id) {
query += ` AND p.reply_to_id = $${params.length + 1} ORDER BY p.created_at ASC`;
params.push(dto.reply_to_id);
} else {
query += ` AND p.reply_to_id IS NULL ORDER BY p.created_at DESC`;
}
query += ` OFFSET $${params.length + 1} LIMIT $${params.length + 2}`;
params.push(dto.offset, dto.limit);
const res = await pool.query(query, params);
return res.rows.map((row) => ({
id: row.id,
text: row.text,
reply_to_id: row.reply_to_id,
created_at: row.created_at,
likes_count: row.likes_count,
views_count: row.views_count,
replies_count: row.replies_count,
user_liked: row.user_liked,
user_viewed: row.user_viewed,
user: {
id: row.user_id,
user_name: row.user_name,
first_name: row.first_name,
last_name: row.last_name,
},
}));
}
Метод getAllPosts
предназначен для получения списка публикаций с расширенной информацией:
автор поста;
количество лайков, просмотров, ответов;
флаги, лайкнул и/или просматривал ли текущий пользователь этот пост.
Структура SQL-запроса
Запрос построен с использованием CTE (Common Table Expressions) и выглядит следующим образом:
WITH likes_count AS (...),
views_count AS (...),
replies_count AS (...)
SELECT ...
FROM posts ...
Рассмотрим все части по порядку.
1. Подсчёт количества лайков к каждому посту
likes_count AS (
SELECT post_id, COUNT(*) AS likes_count
FROM likes
GROUP BY post_id
)
Здесь из таблицы likes
собирается информация о количестве лайков для каждого поста. Используется GROUP BY post_id
, чтобы сгруппировать лайки по постам.
2. Подсчёт количества просмотров
views_count AS (
SELECT post_id, COUNT(*) AS views_count
FROM views
GROUP BY post_id
)
Аналогично первой CTE, но теперь считаются просмотры из таблицы views
.
3. Подсчёт количества ответов на каждый пост
replies_count AS (
SELECT reply_to_id, COUNT(*) AS replies_count
FROM posts
WHERE reply_to_id IS NOT NULL
GROUP BY reply_to_id
)
Здесь из самой таблицы posts
выбираются те строки, где reply_to_id IS NOT NULL
, то есть это ответы на другие посты. Считается, сколько таких ответов у каждого родительского поста.
4. Основной запрос
SELECT
p.id, p.text, p.reply_to_id, p.created_at,
u.id AS user_id, u.user_name, u.first_name, u.last_name,
COALESCE(lc.likes_count, 0) AS likes_count,
COALESCE(vc.views_count, 0) AS views_count,
COALESCE(rc.replies_count, 0) AS replies_count,
CASE WHEN l.user_id IS NOT NULL THEN true ELSE false END AS user_liked,
CASE WHEN v.user_id IS NOT NULL THEN true ELSE false END AS user_viewed
FROM posts p
JOIN users u ON p.user_id = u.id
LEFT JOIN likes_count lc ON p.id = lc.post_id
LEFT JOIN views_count vc ON p.id = vc.post_id
LEFT JOIN replies_count rc ON p.id = rc.reply_to_id
LEFT JOIN likes l ON l.post_id = p.id AND l.user_id = $1
LEFT JOIN views v ON v.post_id = p.id AND v.user_id = $1
WHERE p.deleted_at IS NULL
...
Что здесь происходит:
JOIN users
— соединение поста с его автором поuser_id
;LEFT JOIN
сlikes_count
,views_count
,replies_count
— добавляются данные из CTE о количестве лайков, просмотров и ответов;LEFT JOIN likes l
иviews v
— проверяется, поставил ли лайк или просмотр текущий пользователь ($1
— его id). Эти поля используются в логических выражениях ниже;CASE WHEN ... THEN true ELSE false
— определяетuser_liked
иuser_viewed
;COALESCE(..., 0)
— если данных о лайках/просмотрах/ответах нет (например, никто не лайкал), подставляется0
;WHERE p.deleted_at IS NULL
— фильтрация: берутся только не удалённые посты.
5. Дополнительные фильтры
По тексту:
if (dto.search) {
query += ` AND p.text ILIKE $${params.length + 1}`;
params.push(`%${dto.search}%`);
}
Если передана строка search
, ищутся посты, в тексте которых есть соответствие.
По пользователю (автору):
if (dto.owner_id) {
query += ` AND p.user_id = $${params.length + 1}`;
params.push(dto.owner_id);
}
Если передан owner_id
, отбираются посты конкретного пользователя.
По ответам:
if (dto.reply_to_id) {
query += ` AND p.reply_to_id = $${params.length + 1} ORDER BY p.created_at ASC`;
} else {
query += ` AND p.reply_to_id IS NULL ORDER BY p.created_at DESC`;
}
Проверяется, являются ли посты ответами на другой пост (reply_to_id
) или это корневые посты.
6. Пагинация
query += ` OFFSET $${params.length + 1} LIMIT $${params.length + 2}`;
params.push(dto.offset, dto.limit);
Реализуется механика "скользящего окна" — выбирается определённый диапазон постов.
7. Возвращаемый результат
Результат собирается в виде массива постов. Каждый пост содержит:
данные самого поста,
данные автора (
user
),количество лайков, просмотров, ответов,
флаги
user_liked
,user_viewed
.
Далее рассмотрим реализацию метода getPostById
.
import { pool } from "../db/index.js";
export const PostRepository = {
async getPostById(postId, userId) {
const query = `
WITH likes_count AS (
SELECT post_id, COUNT(*) AS likes_count
FROM likes
GROUP BY post_id
),
views_count AS (
SELECT post_id, COUNT(*) AS views_count
FROM views
GROUP BY post_id
),
replies_count AS (
SELECT reply_to_id, COUNT(*) AS replies_count
FROM posts
WHERE reply_to_id IS NOT NULL
GROUP BY reply_to_id
)
SELECT
p.id AS post_id,
p.text,
p.reply_to_id,
p.created_at,
u.id AS user_id,
u.user_name,
u.first_name,
u.last_name,
COALESCE(lc.likes_count, 0) AS likes_count,
COALESCE(vc.views_count, 0) AS views_count,
COALESCE(rc.replies_count, 0) AS replies_count,
CASE WHEN l.user_id IS NOT NULL THEN true ELSE false END AS user_liked,
CASE WHEN v.user_id IS NOT NULL THEN true ELSE false END AS user_viewed
FROM posts p
JOIN users u ON p.user_id = u.id
LEFT JOIN likes_count lc ON p.id = lc.post_id
LEFT JOIN views_count vc ON p.id = vc.post_id
LEFT JOIN replies_count rc ON p.id = rc.reply_to_id
LEFT JOIN likes l ON l.post_id = p.id AND l.user_id = $1
LEFT JOIN views v ON v.post_id = p.id AND v.user_id = $1
WHERE p.id = $2 AND p.deleted_at IS NULL;
`;
const res = await pool.query(query, [userId, postId]);
if (res.rowCount === 0) {
throw new Error("Post not found");
}
const row = res.rows[0];
return {
id: row.post_id,
text: row.text,
reply_to_id: row.reply_to_id,
created_at: row.created_at,
likes_count: row.likes_count,
views_count: row.views_count,
replies_count: row.replies_count,
user_liked: row.user_liked,
user_viewed: row.user_viewed,
user: {
id: row.user_id,
user_name: row.user_name,
first_name: row.first_name,
last_name: row.last_name,
},
};
},
};
Метод getPostById
используется для получения одного конкретного поста по его идентификатору. Он возвращает расширенную информацию по посту, включая лайки, просмотры, количество ответов и данные об авторе. Метод похож на getAllPosts
, за исключением некоторых отличий.
Фильтрация по ID поста
Вместо выборки множества записей, запрос ограничивается одним постом:
WHERE p.id = $2 AND p.deleted_at IS NULL
Первый параметр ($1
) — это user_id
(нужен для определения, лайкнул ли/просматривал ли пользователь пост), второй ($2
) — это ID самого поста, который ищется.
Отсутствует пагинация
Метод возвращает только один пост, поэтому нет OFFSET
и LIMIT
.
Возвращаемое значение
getPostById
возвращает один объект поста, а getAllPosts
- массив.
Обработка крайних случаев
Если пост не найден, getPostById
выбрасывает исключение "Post not found", а getAllPosts
возвращает пустой массив.
Перейдем к реализации метода deletePost
в репозитории PostRepository
async deletePost(id, ownerId) {
const query = `...`;
const res = await pool.query(query, [id, ownerId]);
if (res.rowCount === 0) {
throw new Error("Post not found or already deleted");
}
}
Задание
Реализуйте метод deletePost
, который помечает пост как удалённый. SQL-запрос должен обновлять поле deleted_at
текущим временем, работать только с постами, принадлежащими автору и исключать уже удалённые посты.
Теперь реализуем метод, который регистрирует факт просмотра поста пользователем. Каждый пользователь может просмотреть пост только один раз — повторные просмотры не записываются.
async function viewPost(postId, userId) {
const query = `...`;
try {
const res = await pool.query(query, [postId, userId]);
if (res.rowCount === 0) {
throw new Error("Post not found");
}
} catch (err) {
if (err.message.includes("pk__views")) {
throw new Error("Post already viewed");
}
throw err;
}
}
Внимание
Обратите внимание на строку err.message.includes("pk__views")
. Здесь pk__views
- это имя первичного ключа у таблицы views
. Подставьте свое, если у вас отличается.
Задание
Реализуйте метод viewPost
, который добавляет новую запись в таблицу views
.
Теперь реализуем метод, который позволяет пользователю поставить лайк посту. Один пользователь может поставить лайк одному посту только один раз — повторные попытки должны вызывать ошибку.
async function likePost(postId, userId) {
const query = `...`;
try {
const res = await pool.query(query, [postId, userId]);
if (res.rowCount === 0) {
throw new Error("Post not found");
}
} catch (err) {
if (err.message.includes("pk__likes")) {
throw new Error("Post already liked");
}
throw err;
}
}
Внимание
Обратите внимание на строку err.message.includes("pk__likes")
. Здесь pk__likes
- это имя первичного ключа у таблицы likes
. Подставьте свое, если у вас отличается.
Задание
Реализуйте метод likePost
, который добавляет новую запись в таблицу likes
.
Метод dislikePost
позволяет пользователю убрать лайк с поста, если он его ранее поставил.
async function dislikePost(postId, userId) {
const query = `...`;
const res = await pool.query(query, [postId, userId]);
if (res.rowCount === 0) {
throw new Error("Post not found");
}
}
Задание
Реализуйте метод dislikePost
, который удаляет запись из таблицы likes
.
Тестирование репозитория постов
В папке __tests__/repositories
создайте файл postRepository.test.js
и поместите в него код с unit-тестами:
Unit-тесты postRepository
import { describe, expect, jest } from "@jest/globals";
import { pool } from "../../src/config/db.js";
import { PostRepository } from "../../src/repositories/postRepository.js";
function normalizeSQL(sql) {
return sql.toLowerCase().replace(/\s+/g, " ").trim();
}
describe("PostRepository", () => {
afterEach(() => {
jest.clearAllMocks();
});
describe("createPost", () => {
it("should successfully create a post", async () => {
const mock = jest.spyOn(pool, "query");
const dto = {
text: "Lorem ipsum dolor sit amet, consectetur adipiscing",
user_id: 1,
reply_to_id: null,
};
const expected = {
id: 1,
text: dto.text,
created_at: new Date(),
reply_to_id: null,
};
mock.mockResolvedValueOnce({ rows: [expected], rowCount: 1 });
const result = await PostRepository.createPost(dto);
expect(result).toEqual(expected);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("insert into posts");
expect(params).toEqual([dto.text, dto.user_id, dto.reply_to_id]);
});
it("should return error on insert failure", async () => {
const mock = jest.spyOn(pool, "query");
const dto = {
text: "Lorem ipsum dolor sit amet, consectetur adipiscing",
user_id: 1,
reply_to_id: null,
};
const fakeError = new Error("insert failed");
mock.mockRejectedValueOnce(fakeError);
await expect(PostRepository.createPost(dto)).rejects.toThrow(
"insert failed"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("insert into posts");
expect(params).toEqual([dto.text, dto.user_id, dto.reply_to_id]);
});
});
describe("getAllPosts", () => {
it("should successfully return all posts", async () => {
const mock = jest.spyOn(pool, "query");
const now = new Date("2025-04-24T20:55:53.021Z");
const dto = {
user_id: 1,
owner_id: 0,
limit: 100,
offset: 0,
reply_to_id: 1,
search: "test",
};
const rows = [
{
id: 1,
text: "Post 1",
reply_to_id: null,
created_at: now,
likes_count: 10,
views_count: 100,
replies_count: 0,
user_liked: true,
user_viewed: true,
user_id: 1,
user_name: "username",
first_name: "first",
last_name: "last",
},
{
id: 2,
text: "Post 2",
reply_to_id: null,
created_at: now,
likes_count: 5,
views_count: 50,
replies_count: 2,
user_liked: false,
user_viewed: true,
user_id: 1,
user_name: "username",
first_name: "first",
last_name: "last",
},
];
mock.mockResolvedValueOnce({ rows, rowCount: rows.length });
const result = await PostRepository.getAllPosts(dto);
expect(result).toHaveLength(2);
expect(result[0].id).toBe(1);
expect(result[1].likes_count).toBe(5);
const [sql, params] = mock.mock.calls[0];
const normalized = normalizeSQL(sql);
expect(normalized).toContain("select");
expect(params[0]).toBe(dto.user_id);
});
it("should return error on SQL failure", async () => {
const mock = jest.spyOn(pool, "query");
const dto = {
user_id: 1,
owner_id: 0,
limit: 100,
offset: 0,
reply_to_id: 1,
search: "test",
};
mock.mockRejectedValueOnce(new Error("query failed"));
await expect(PostRepository.getAllPosts(dto)).rejects.toThrow(
"query failed"
);
const [sql, params] = mock.mock.calls[0];
const normalized = normalizeSQL(sql);
expect(normalized).toContain("select");
expect(params[0]).toBe(dto.user_id);
});
});
describe("getPostById", () => {
it("should successfully get post by ID", async () => {
const userId = 1;
const postId = 1;
const now = new Date();
const row = {
post_id: postId,
text: "Lorem ipsum dolor sit amet, consectetur adipiscing",
reply_to_id: null,
created_at: now,
user_id: userId,
user_name: "username",
first_name: "first_name",
last_name: "last_name",
likes_count: 10,
views_count: 100,
replies_count: 0,
user_liked: true,
user_viewed: true,
};
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rows: [row], rowCount: 1 });
const result = await PostRepository.getPostById(postId, userId);
expect(result).toEqual({
id: row.post_id,
text: row.text,
reply_to_id: row.reply_to_id,
created_at: row.created_at,
likes_count: row.likes_count,
views_count: row.views_count,
replies_count: row.replies_count,
user_liked: row.user_liked,
user_viewed: row.user_viewed,
user: {
id: row.user_id,
user_name: row.user_name,
first_name: row.first_name,
last_name: row.last_name,
},
});
const [sql, params] = mock.mock.calls[0];
expect(normalizeSQL(sql)).toContain("select");
expect(params).toEqual([userId, postId]);
});
it("should throw error if post not found", async () => {
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rows: [], rowCount: 0 });
await expect(PostRepository.getPostById(999, 1)).rejects.toThrow(
"Post not found"
);
const [sql, params] = mock.mock.calls[0];
expect(normalizeSQL(sql)).toContain("select");
expect(params).toEqual([1, 999]);
});
});
describe("deletePost", () => {
it("should successfully delete post", async () => {
const postId = 1;
const ownerId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rowCount: 1 });
await expect(
PostRepository.deletePost(postId, ownerId)
).resolves.toBeUndefined();
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("update posts set deleted_at = now()");
expect(normalizedSQL).toContain("where id = $1 and user_id = $2");
expect(params).toEqual([postId, ownerId]);
});
it("should return error if post not found", async () => {
const postId = 2;
const ownerId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rowCount: 0 });
await expect(PostRepository.deletePost(postId, ownerId)).rejects.toThrow(
"Post not found or already deleted"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("update posts set deleted_at = now()");
expect(params).toEqual([postId, ownerId]);
});
});
describe("viewPost", () => {
it("should successfully register a view", async () => {
const postId = 1;
const userId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rowCount: 1 });
await expect(
PostRepository.viewPost(postId, userId)
).resolves.toBeUndefined();
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("insert into views (post_id, user_id)");
expect(params).toEqual([postId, userId]);
});
it("should throw error on SQL failure", async () => {
const postId = 2;
const userId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockRejectedValueOnce(new Error("insert failed"));
await expect(PostRepository.viewPost(postId, userId)).rejects.toThrow(
"insert failed"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("insert into views (post_id, user_id)");
expect(params).toEqual([postId, userId]);
});
it("should throw already viewed error on unique constraint", async () => {
const postId = 3;
const userId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockRejectedValueOnce(
new Error('duplicate key value violates unique constraint "pk__views"')
);
await expect(PostRepository.viewPost(postId, userId)).rejects.toThrow(
"Post already viewed"
);
});
});
describe("likePost", () => {
it("should successfully like a post", async () => {
const postId = 1;
const userId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rowCount: 1 });
await expect(
PostRepository.likePost(postId, userId)
).resolves.toBeUndefined();
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("insert into likes (post_id, user_id)");
expect(params).toEqual([postId, userId]);
});
it("should return error if like fails", async () => {
const postId = 2;
const userId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockRejectedValueOnce(new Error("insert failed"));
await expect(PostRepository.likePost(postId, userId)).rejects.toThrow(
"insert failed"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain("insert into likes (post_id, user_id)");
expect(params).toEqual([postId, userId]);
});
it("should return 'already liked' error if constraint is violated", async () => {
const postId = 3;
const userId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockRejectedValueOnce(
new Error('duplicate key value violates unique constraint "pk__likes"')
);
await expect(PostRepository.likePost(postId, userId)).rejects.toThrow(
"Post already liked"
);
});
});
describe("dislikePost", () => {
it("should successfully remove a like from a post", async () => {
const postId = 1;
const userId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rowCount: 1 });
await expect(
PostRepository.dislikePost(postId, userId)
).resolves.toBeUndefined();
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain(
"delete from likes where post_id = $1 and user_id = $2"
);
expect(params).toEqual([postId, userId]);
});
it("should return error if dislike fails", async () => {
const postId = 2;
const userId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockRejectedValueOnce(new Error("delete failed"));
await expect(PostRepository.dislikePost(postId, userId)).rejects.toThrow(
"delete failed"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain(
"delete from likes where post_id = $1 and user_id = $2"
);
expect(params).toEqual([postId, userId]);
});
it("should return error if like does not exist", async () => {
const postId = 3;
const userId = 1;
const mock = jest.spyOn(pool, "query");
mock.mockResolvedValueOnce({ rowCount: 0 });
await expect(PostRepository.dislikePost(postId, userId)).rejects.toThrow(
"Post not found"
);
const [sql, params] = mock.mock.calls[0];
const normalizedSQL = normalizeSQL(sql);
expect(normalizedSQL).toContain(
"delete from likes where post_id = $1 and user_id = $2"
);
expect(params).toEqual([postId, userId]);
});
});
});
Запустите тесты. Если вы все сделали правильно, все тесты пройдены.
npm run test
> gophertalk-backend-express@0.1.0 test
> node --experimental-vm-modules node_modules/jest/bin/jest.js
(node:26001) ExperimentalWarning: VM Modules is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)
PASS __tests__/repositories/postRepository.test.js
PASS __tests__/repositories/userRepository.test.js
Test Suites: 2 passed, 2 total
Tests: 30 passed, 30 total
Snapshots: 0 total
Time: 0.196 s, estimated 1 s
Ran all test suites.
Итог
Мы последовательно разработали два слоя доступа к данным — репозиторий пользователей и репозиторий постов, следуя архитектурному принципу разделения ответственности. Мы:
Создали функции для основных операций с базой данных (создание, чтение, обновление, удаление).
Реализовали SQL-запросы с использованием позиционных параметров, обеспечивающих защиту от SQL-инъекций.
Поддержали гибкие фильтры, пагинацию и условия отбора данных (например, по
user_id
,reply_to_id
,text
).Обработали все возможные ошибки, включая ситуации "не найдено" и конфликты при повторных действиях (например, повторный лайк).
Написали юнит-тесты, чтобы убедиться в корректности реализации всех функций.
Такой подход делает код читаемым, легко поддерживаемым и расширяемым. Теперь мы готовы перейти к разработке следующего слоя — функционального (сервисов), где будет реализована логика обработки данных и проверок перед их отправкой в репозитории.